Skip to content

x-office-u Office 文档编辑 ​

基于 LibreOffice 内核(Collabora 的 COKit)的 Office 文档编辑组件。doc/docx/xls/xlsx/ppt/pptx/odf 完整编辑,三端同一套编辑前端,文档解析与编辑全部在设备本地完成。

兼容性 ​

HarmonyiOSAndroidWEB小程序
支持支持支持需自建部署不支持

App 三端能力对等:编辑 UI 是同一套 Collabora Online 的 JS 前端,跑在各端 WebView 里 (Android WebView / iOS WKWebView / HarmonyOS ArkUI Web),各端只提供 WebView 宿主 与引擎桥。因此三端不存在功能差集。

Web 端走 wasm,见文末「Web 端」。小程序不支持:既没有 native 扩展也起不了 wasm 线程。

引擎产物不随插件仓库分发 ​

引擎是 200~300MB 量级的 native 产物,不进 git。插件在没有引擎的情况下也能编译、 能加载,此时 getRuntimeInfo().engineReady 为 false,open() 会以 errCode: 1801 失败并在 errMsg 里给出该跑哪个脚本。出货前必须先编引擎:

bash
# 编辑前端(COOL):三端共用同一份,只需编一次。WebView 里的整套编辑 UI
# (工具栏、对话框、批注、公式栏)都在这里,缺了它插件只有 API 没有界面。
cd third_party/collabora/online
./autogen.sh && ./configure --enable-androidapp --with-lo-builddir="$PWD/engine"
make                                     # 产出 browser/dist,约 1 分钟

cd uni_modules/x-office-u/native-src

# HarmonyOS:本仓给 LibreOffice 构建系统新增了 OHOS 平台(上游零支持),
# 补丁在 third_party/collabora/online/engine 下,详见 harmony/build_engine_oh.sh 头注释
bash harmony/build_engine_oh.sh          # 交叉编译引擎到 aarch64-linux-ohos
bash harmony/build_oh.sh --with-lok      # 打成 utssdk/app-harmony/libs/x-office-native.har

# Android:上游官方支持,只需固定参数
bash android/build_engine_android.sh
bash android/build_android.sh --with-lok # 打成 utssdk/app-android/libs/x-office-native.aar

# iOS:上游官方支持,宿主必须 macOS + Xcode
bash ios/build_engine_ios.sh
bash ios/build_ios.sh --with-lok         # 聚合成 utssdk/app-ios/Frameworks/XOfficeCore.xcframework

不带 --with-lok 时三个打包脚本只编桥接层,产出的是不含引擎的空壳,用于跑通 UTS / Kotlin / Swift / ArkTS 侧全链路与 CI 回归。

三端不能共用一个引擎构建目录 ​

引擎是单目标树:config_host.mk 里只有一个 OS=,autogen.sh 每跑一次就把整棵树 重新配置到新目标上。三端如果都在 engine/ 里就地编,后编的会把先编的 workdir / instdir 连同已经链好的 .so 全部作废。

LibreOffice 支持 BUILDDIR != SRCDIR,所以每个目标一个独立构建目录,共用同一份源码:

目标BUILDDIR引擎产物
HarmonyOSonline/engine(就地,历史原因)engine/ohos/libs/arm64-v8a/liblo-native-code.so
Androidonline/engine-build/android<BUILDDIR>/android/jniLibs/arm64-v8a/liblo-native-code.so
iOSonline/engine-build/ios<BUILDDIR>/workdir/CustomTarget/ios/ios-all-static-libs.list

用 X_OFFICE_ENGINE_BUILD 可以覆盖。common/engine_builddir.sh 里的守卫会在 autogen.sh 之前比对该目录当前配的 OS=,不一致就直接拒绝——autogen.sh 是就地覆盖 配置的,跑错目录没有回头路。比对忽略大小写:configure 写进去的是 OS=iOS, 而脚本传的是 IOS,直接字符串比会让 iOS 编过一次之后再也无法重跑。

macOS 上编 Android 引擎 ​

能编,但要绕一个上游的写死值。distro-configs/CPAndroidCommon.conf 第一行是 --build=x86_64-unknown-linux-gnu,而 configure.ac 是拿 $build_os 去选 NDK 的 prebuilt/ 子目录,于是在 macOS 上会去找根本不存在的 prebuilt/linux-x86_64/bin/clang, 第一个 C 编译器检查就报 C compiler cannot create executables。

build_engine_android.sh 会在 Darwin 宿主上自动追加 --build=$(config.guess) 盖回来 (autogen.sh 就地展开 --with-distro,这行排在它后面,autoconf 的 --build 后者胜出)。 注意 config.guess 在树里是 644 没有执行位,必须经 shell 调用,上游 configure 自己 也是这么干的;直接 exec 会 permission denied 并静默得到空串。

挂后台跑 ​

引擎全量构建三小时以上,nohup 挡不住——它只忽略 SIGHUP,进程仍在调用方的进程组里, 进程组一拆整棵子树跟着没。用 common/run_detached.sh(python3 的 start_new_session, 即真正的 setsid;macOS 没有 setsid 命令):

bash
PID=$(bash common/run_detached.sh build/logs/engine-ios.log bash ios/build_engine_ios.sh)

# 两端引擎都在跑时,等它们收工并自动重打包
bash common/run_detached.sh build/logs/finish-all.log \
    bash common/finish_all.sh <iOS PID> <Android PID>
tail -f build/logs/finish-all.status

只改了引擎里一两个源文件时别跑整脚本 ​

build_engine_oh.sh 是为「从零 clone 到出包」设计的,改一个 .cxx 再跑它会退化成近似 全量(三小时):[3/4] autogen.sh 会重跑 configure、重新生成 config_host.mk 与 config_host/*.h,而 gbuild 里几乎每个 object 都依赖这些配置头,mtime 一变就级联; [4/4] make build 又是顶层全量遍历,含 260+ 个 external 归档的检查。而补丁已在树中、 autogen.input 没变时,前两步本来就无事可做。

最小增量是两条命令(实测改 bridges 里一个源文件:重编 5 秒 + 重链 17 秒):

bash
# 必须是 GNU make 4.x:macOS 自带的 3.81 会在 LinkTarget.mk 上报
# "extraneous `endef'"。build_engine_oh.sh 靠这行 PATH 拿到 gmake,手跑增量时要自己带上。
export PATH="/opt/homebrew/opt/make/libexec/gnubin:/opt/homebrew/opt/gnu-sed/libexec/gnubin:/opt/homebrew/opt/gnu-tar/libexec/gnubin:$PATH"

cd third_party/collabora/online/engine
make -C bridges -j8          # 只重编改动的 object,重打 libgcc3_uno.a 并交付 instdir/
make -C ohos/source all      # 归档变新即触发重链,再 strip 到 ohos/libs/arm64-v8a/

实测改 desktop/source/lib/init.cxx:重编 13 秒 + 重链 13 秒。

第二条等价于 CustomTarget_lo_ohos.mk 里那条链接规则,只是省掉了 AllModulesButInstsetNative 的全量遍历。把 bridges 换成你改的模块即可。 之后照常 bash harmony/build_oh.sh --with-lok 重打 HAR(约 1 分钟)。

改完务必确认新产物的 build-id 变了(llvm-readelf -na <so> | grep -A2 'Build ID'), build-id 没变说明根本没重链,后面所有验证都是在看旧产物。

另外判断脱离出去的构建有没有结束要看日志末尾的 exit 标记,不能靠 ps -p: run_detached.sh 用的是 start_new_session,脱离后的进程在沙箱里 ps 看不到, 会误判成已结束。

打包脚本会先把 browser/dist 过一遍 common/stage_cool_dist.sh 再入包,删掉这份 构建里没有入口能走到的部分(未压缩的 js 源码、wasm 版才用的 l10n-all.js、 coolwsd 管理控制台、非 en 的帮助截图),49MB → 26MB。两个开关:

环境变量作用
X_OFFICE_COOL_FULL=1原样入包不裁剪,排查前端问题时用
X_OFFICE_COOL_TEMPLATES=1保留「从模板新建文档」的模板库(+8MB)。本插件 API 只有 open(既有路径),默认不带

体积提示:单端引擎 200~300MB,远超 HBuilderX 云打包免费额度(60MB),需走离线打包。 鸿蒙侧实测 x-office-native.har 108MB(引擎 .so 179MB + 引擎资源包 36MB + 前端 5.7MB)。

前端在各端的落地方式不同 ​

端随包位置页面地址
Androidassets/x-office-u/file:///android_asset/x-office-u/cool.html
iOSbundle 内bundle 的 file://
HarmonyOSrawfile/x-office-u/cool.zip首启解到沙箱后的 file://

三端页面源统一是 file://(Origin 都是字面量 null),与上游 Android 的 file:///android_asset/dist/cool.html 同构,cool: 那侧的 CORS 放行规则因此完全一致。

鸿蒙这一端要多解一次包,不是为了省体积,是因为 resource://rawfile/xxx.html?a=1 一律 ERR_FILE_NOT_FOUND —— rawfile 把整个 URL 当条目名去查表。而前端要的文档路径、 权限、语言全靠这串 query 传进去,没有替代通道。

代价是 file:// 页面默认不能跨源,三端各要开一个口:Android setAllowUniversalAccessFromFileURLs(true);iOS 靠 setURLSchemeHandler 注册 cool: (注册过的 scheme 才允许被 fetch),页面同目录资源另由 loadFileURL(_:allowingReadAccessTo:) 给读权限;鸿蒙是 customizeSchemes (isSupportCORS / isSupportFetch)加 setPathAllowingUniversalAccess([<沙箱>/x-office-u])。鸿蒙那个名单还会覆盖fileAccess:设了之后 file:// 只能读名单内的路径,所以给的是整个根目录 而不是 cool/ 子目录。

Android 别改用 WebViewAssetLoader 的 https 虚拟域 ​

https://appassets.androidplatform.net/assets/... 看着比 file:// 现代,实际会让 安卓永远打不开文档,而且一条错误都不打。页面源一旦是 https,回程那条发往 cool: 的 XHR 就成了「安全页面取不安全资源」,内核按混合内容直接拦掉,接着一路静音:

  • 前端的 ProxySocket.getSessionId() 只给 XHR 挂了 load 监听,没挂 error (js/global.js),请求被拦掉连 _signalErrorClose() 都走不到;
  • 于是 socket 的 readyState 永远停在 0。HULLO 是走 JS 桥发的,引擎那边照常连上、 日志正常,但 js/global.js 里发 load url= 的 onopen 处理器第一句就是 if (readyState === 1),永不成立 —— 引擎没收到加载请求,宿主也就等不到 LOADED, open() 的回调一个都不回。

现场只剩一条无关的 E WebViewAssetLoader: ... x-office-u/branding-mobile.css (就是下面「那 4 条 ERR_FILE_NOT_FOUND 是正常的」说的那批可选资源),照着它查会 走进死胡同。allowFileAccess 保持关不影响这条路:那个开关管的是文件系统, file:///android_asset 与 file:///android_res 始终可读(上游 app targetSdk 35、 同样没开它)。

重建前端产物(改了 browser/src 之后) ​

三端消费的是同一份 third_party/collabora/online/browser/dist,重建一次三端一起生效:

bash
export PATH="/opt/homebrew/opt/make/libexec/gnubin:$PATH"   # 要 GNU make 4.x
make -C third_party/collabora/online/browser dist/bundle.js  # 约 15 秒

会顺带跑 tsc 与 tsc-strict(351 个 strict 文件)和一次 ES2022 语法体检, 所以改错了这里就会拦下来。跑完再照常 build_oh.sh --with-lok 打包。

如果它报 Cannot find module '../findStrictErrors':node_modules/.bin/ 里的 shim 本该是指向各包 bin 入口的符号链接,但这份 node_modules 是在不支持符号链接的 卷上装的,71 个 shim 全落成了实体副本 —— 副本放在 .bin/ 下,里头的相对 require('../xxx') 就从 .bin/ 出发解析,全部指错。按各包 package.json 的 bin 字段重建符号链接即可(tsc-emit 不受影响,因为 Makefile 直接调 node_modules/typescript/bin/tsc,没走 .bin)。

online(COOL)侧的改动也靠补丁保存 ​

third_party/ 不入库,所以那 12 个文件的改动固化在 native-src/common/patches/0001-online-x-office-app.patch(common/、kit/、 wsd/、configure.ac,以及上面那个前端修复)。引擎侧的 7 个补丁在 native-src/harmony/patches/,由 build_engine_oh.sh 自动应用; online 侧这一份目前还没接进构建脚本,新克隆需要手动 git apply 一次(在 third_party/collabora/online/ 下)。

引擎起来之前要摆好的环境变量 ​

kit_cpp_init() 之前 native-src/common/XOfficeEngine.cpp 会设几个环境变量,鸿蒙与 Android 都要(iOS 不需要,上游 iOS 分支自己带了一套等价的 bundle 布局):

变量没有它会怎样
URE_UNO_INI_URIUNO bootstrap 起不来
TMPDIRDesktop::CreateTemporaryDirectory() 建不出目录
XDG_CACHE_HOMEfontconfig 缓存写不下去,每次启动重扫全部字体
HOME$SYSUSERHOME / $SYSUSERCONFIG 展开失败

URE_UNO_INI_URI 是硬性的。cppu::getUnoIniUri()(cppuhelper/source/paths.cxx)猜 unorc 位置的默认逻辑在这两端都指不到我们的资源目录:鸿蒙落到 get_this_libpath(), 那是 .so 所在的 libs/arm64;Android 分支更硬,写死 file:///assets/program。而资源 是首启解到沙箱的。读不到 unorc 就没有 UNO_TYPES / UNO_SERVICES。

这条路失败的现场极难认:cokit_hook_2() 初始化失败后会 delete 那个半成品 COKitImpl,而它的析构第一件事就是取 SolarMutex —— 此时 VCL 还没起来,SalInstance 是空的,当场 SIGSEGV,栈顶只看得到 SalInstance::GetYieldMutex(),看不到任何真因 (更糟的是 delete 之后它还把这个野指针返回给调用方)。所以引擎的失败路径根本走不 回来,sharedOfficeHandle() 在进引擎之前先自己查一遍 sofficerc / fundamentalrc / unorc / services.rdb / types.rdb 齐不齐,缺哪个就打日志直接返回,不进引擎。

排查用 native 日志(tag 一律 x-office-u):鸿蒙 hdc hilog | grep x-office-u, Android adb logcat -s x-office-u,iOS 看 Xcode 控制台。

aarch64 UNO 桥的 __clear_cache(鸿蒙专属,补丁 0002) ​

bridges/source/cpp_uno/gcc3_linux_aarch64/cpp2uno.cxx 的 VtableFactory::flushCode() 在非 ANDROID / 非 MACOSX 分支下是这么取函数指针的:

cpp
static void (*clear_cache)(...) = ...(dlsym(RTLD_DEFAULT, "__clear_cache"));
(*clear_cache)(begin, end);          // 没有判空

__clear_cache 是 compiler-rt 的内建实现,静态链进各目标且符号隐藏,OHOS 上没有任何 动态库导出它,所以 dlsym 恒为 NULL,这行直接跳到地址 0。上游只给 Android 和 macOS 开了 __builtin___clear_cache 分支,补丁 0002 把 OHOS 也放进去。

这个崩溃的现场同样具有欺骗性,值得记一笔:

  • flushCode 被编译成尾调用(ldp x29,x30 之后 br x2),所以栈里根本没有它自己 这一帧,最内层可见的是调用方 VtableFactory::createVtables,看上去像 vtable 生成本身 出了问题。
  • 触发点毫无特征。任何一次抛 UNO 异常都要现场造 C++ 接口代理,而打开文档时 LayoutManager 复位会经 UCB 去 stat 一个不存在的配置文件 —— 那本是完全正常、可恢复的 异常,却成了压垮进程的第一根稻草。于是栈顶看着像「docx 导入过滤器崩了」, 实际和文件内容、writerfilter、字体都无关。
  • Android 不受影响(走 ANDROID 分支,编译成静态绑定的 bl __clear_cache),所以这是 纯鸿蒙端问题,双端对照反而会误导。

认这个崩溃只需要看两点:SIGSEGV 目标地址为 0x0、且栈顶 pc 0 Not mapped。 崩溃日志里的 pc 是已经 -4 调整过的(指向 bl 指令本身而非返回地址), 符号化时直接把偏移喂给 llvm-symbolizer --obj=<未 strip 的 so> 即可,不用换算 (该 .so 可执行 LOAD 段的 VirtAddr 与 Offset 差 0x4000,按文件偏移解会得到 一整套看似合理但完全错误的函数名 —— 用 libx_office_u.so 那个带符号的帧交叉校验)。

未 strip 的产物在 engine/ohos/obj/local/arm64-v8a/,HAR 里那份是 strip 过的。

「跳空指针」这一类隐患(dlsym 取到 NULL 后无判空就调用)已审计完,只有上面这一处:

位置为什么不是问题
sal/osl/unx/random.cxx判空后退回 open("/dev/urandom")
sal/osl/unx/process_impl.cxx结果查 != nullptr
vcl/source/window/builder.cxx 的 .ui 自定义控件工厂整块在 #ifndef DISABLE_DYNLOADING 内,OHOS 不编译
gcc3_linux_x86-64 / gcc3_linux_intel / gcc3_linux_arm不是本工程的架构

但「dlsym 查不到」本身还有第二种危害形态,比跳空指针更隐蔽:查不到时不崩,而是 悄悄换出一个语义不等价的替代品。上面那张表最初把 abi.cxx 的 RTTI 查找判成「不是问题, 因为它 dlsym 失败会自己合成 type_info」——恰恰是那个合成动作有问题,见下一节。

UNO 异常的 RTTI 查找域(鸿蒙专属,补丁 0003) ​

补丁 0002 修好之后暴露出来的下一个问题:打开任意文档时 SIGABRT,

text
LastFatalMessage: terminating due to uncaught exception of type
                  com::sun::star::ucb::InteractiveAugmentedIOException

而栈里第 21 帧的 utl::UCBContentHelper::IsDocument() 明明写着 catch (cpo::uno::Exception const &) { return false; }。异常抛出了,catch 没匹配上。

bridges/source/cpp_uno/gcc3_linux_aarch64/abi.cxx 的 Rtti 这样查 typeinfo:

cpp
Rtti(): app_(dlopen(nullptr, RTLD_LAZY)) {}
...
rtti = static_cast<std::type_info *>(dlsym(app_, "_ZTIN3com3sun4star3ucb31Interactive...E"));
if (rtti == nullptr) {
    // 查不到就现造一个,名字是 strdup 出来的
    rtti = new __cxxabiv1::__si_class_type_info(rttiName, base);
}

dlopen(nullptr) 给的是主程序的搜索域。鸿蒙上 liblo-native-code.so 是被 libx_office_u.so 以 DT_NEEDED 带进来的,而后者是 ArkTS 的 NAPI 模块加载器 dlopen 进来的(局部域),主程序域里看不到引擎的符号,于是 dlsym 恒为 NULL。

关键在于失败之后:合成出来的 __si_class_type_info 名字是 strdup 的新指针。而 libc++abi 判基类关系走的是 is_equal(this, dst_type, /*use_strcmp=*/false)—— 指针比较。于是合成节点和编译期绑定的真 typeinfo 永远不相等, catch (cpo::uno::Exception const &) 匹配不上,异常一路穿到宿主运行时 std::terminate。

也就是说,所有经 cppu::throwException()(UNO Any → C++ throw)抛出的异常在鸿蒙上都 是不可捕获的,而 LibreOffice 内部把 UNO 异常当正常控制流用。补丁 0003 让 OHOS 用 dladdr + dlopen(<自身路径>, RTLD_NOLOAD) 拿到引擎自己这个 .so 的句柄再查。

几个容易带偏的点:

  • typeinfo 符号本身是导出的(.dynsym 里有 1395 个 _ZTIN3com3sun4star*), 所以「符号没导出」的方向是错的,问题只在查找句柄。
  • C++ 原生 throw 完全不受影响,用的是编译期绑定的真 typeinfo。所以 open:fail Unsupported URL <...>: "type detection failed"(LoadEnv 里裸 throw) 一直能正常返回——同一个函数里,有的异常能捕获有的会崩进程,只看它是不是走 UNO Any。
  • 触发路径和文档内容无关:LayoutManager 复位要问「用户 UI 配置存储在不在」, IsDocument() 对不存在的路径抛 IO 异常,这本是正常可恢复的一步。
  • 崩的是 SIGABRT 不是 SIGSEGV,且 LastFatalMessage 会直接点名异常类型—— 看到这行就该往「catch 没匹配上」而不是「哪里写坏了」的方向查。

桥接层另有一道独立的兜底:common/XOfficeEngine.cpp 的所有对外函数都套了 guarded() / guardedJson(),里面是 catch (...)。catch (...) 与 typeinfo 无关、 永远匹配,所以即便以后再出现这类不可捕获的异常,也只会降级成一条 open:fail internal engine error <类型名> 加一条 native 日志,不会再带走整个进程。 类型名是用 abi::__cxa_current_exception_type() 取的,这个接口在 catch (...) 里照样有效。

报到 internal engine error <...> 说明异常穿出了 C 接口(补丁 0004) ​

上面那道兜底只能报出异常类型名,报不出消息——catch (...) 拿不到对象。所以一旦看到 open:fail internal engine error <...>,真正该修的是「为什么异常能走到这一层」:COKit 的 C 接口契约是不抛异常,失败返回 nullptr 并把原因留在 getError()。

已知一处是 lo_documentLoadWithOptions() 把 frame::Desktop::create(xContext) 写在了那个 大 try 之外,而它拿不到服务时抛 DeploymentException。补丁 0004 给它单独套了一层 catch。以后再遇到别的类型名,按同样思路找 try 覆盖不到的语句,别只在桥接层加分支。

为了不必每次都靠猜,impl::openDocument() 对引擎调用做了逐步标注: documentLoadWithOptions / registerCallback / initializeForRendering / getDocumentSize / getParts 各记一个 step 名,整段再套一层 stderr 捕获。所以报错长这样—— open:fail initializeForRendering threw <...>; engine said: ...,一眼能定位到调用点。

doc_iniUnoCommands 不能在鸿蒙上建 NSS 安全环境(鸿蒙专属,补丁 0005) ​

现象是打开任意文档报 open:fail initializeForRendering threw <com::sun::star::uno::DeploymentException>。

doc_initializeForRendering 第一件事就是 doc_iniUnoCommands(),而它会去建 com.sun.star.xml.crypto.SEInitializer(NSS 那套安全环境,只有文档签名用得到)。 SEInitializer::create() 是生成的单例服务工厂,拿不到服务时抛 DeploymentException 而不是返回空引用,所以紧跟其后的 is() 判空形同虚设;而 doc_initializeForRendering 全函数没有 try/catch,异常直接穿出 C 接口。

上游本来就把这段排除在移动端之外(#if !defined IOS && !defined ANDROID && !defined __EMSCRIPTEN__),只是没有 OHOS 这一项,于是鸿蒙落进桌面分支。这和补丁 0002 的 __clear_cache 是同一类问题:上游所有移动端判断都按 IOS/ANDROID 写死,鸿蒙两个都不满足。 以后凡是「Android/iOS 正常、只有鸿蒙炸」的症状,先去看那段代码有没有这种形式的 #if。

注意同文件 doc_addCertificate() 里还有一处同样的 SEInitializer::create(),没有加守卫—— 那是签名 API,我们不调用,所以留着。也因此不能靠 strings 里有没有 com.sun.star.xml.crypto.SEInitializer 来判断补丁生效,那个字符串由后者贡献。

SolarMutex 的交接有两半,鸿蒙一半都没有(鸿蒙专属,补丁 0006) ​

这个的症状最难查:前端白屏,而且没有任何报错。COOL 服务端一路正常 —— 文档 staged 到位、 DocumentBroker 与 ClientSession 都建起来、Loading [...] for session [001]、perm: edit、 lokitversion 也回给前端了 —— 然后停在 ToClient-001: Requesting document load from child,之后 kit 侧再无一条日志。

分辨的关键是别被 lokit_main_001 阻塞误导:DOCS_SHARE_PROCESS 下它就该停在条件变量上 (kit/Kit.cpp 的 termination->cv.wait),真正干活的是 lokit_runloop 线程上的 pollCallback()。采样那条线程的 CPU 时间就露馅了 —— 累计 1 个 tick 且不增长, 说明它不是在轮询,是从进 runLoop 起就阻塞:

hdc shell "cat /proc/<pid>/task/<tid>/stat"   # 取第 14/15 个字段 utime+stime,隔几秒采两次

根因是上游把 SolarMutex 的交接拆成两半,两半的守卫都不含 OHOS:lo_initialize() 里 #ifdef IOS 那段在初始化期 InitVCL() 并 release() 把锁交出去,lo_runLoop() 里 #if defined(IOS) || defined(ANDROID) 那段 acquire() 接住。少了第一半,锁没人交出来, lo_runLoop() 的 SolarMutexGuard 就永远等着。

两处必须一起改:只放开 lo_runLoop 那一处会更糟,那是从一把没人释放的锁上去 acquire()。

我们该走 iOS 这条而不是上游 Android 那条,因为生命周期就是 iOS 那套:启动时先建好唯一引擎实例、 主循环另起线程(见 XOfficeMobileApp.cpp 的 runKitLoopInAThread 一段)。

Android 插件也踩了这把锁,缺的是另一半(补丁 native-src/android/patches/0001-engine-android-solarmutex-handoff.patch)。 lo_runLoop() 的守卫本来就有 ANDROID,一进门 acquire();但 lo_initialize() 的 iOS 那段 InitVCL + release 不能加 ANDROID(HAVE_FEATURE_ANDROID_KIT 的扩展仓库同步必须排在 InitVCL 前面)。unipoll 下 Android 走的是后面那句 else InitVCL(),锁留在初始化线程。上游 Collabora Android 没事,因为它的 runLoop 和 InitVCL 在同一条 lokit_main 上,acquire() 是递归的;我们把 Android 也改成 iOS 生命周期之后,两条线程,不在 lo_initialize() 返回前 ReleaseSolarMutex(),lokit_runloop 的 CPU 从进 runLoop 起就是 0,文档停在 Requesting document load from child,回程只有握手那一包,画布空白。

锁交出去之后立刻撞上下一枪:documentLoad 5ms 闪退, SalAbort: Unspecified application error,进程 _exit(1),没有 tombstone。 空 SalAbort + 退出码 1 是 VCL 信号处理器的招牌(Desktop::Exception), 不是 osl 那条 _exit(255)。崩点是 LiblangtagDataRef::setupDataPath() 对 lo_get_app_data_dir() 做 OString():官方 Collabora Android 在 Java Bootstrap 里填 data_dir, 我们是 C++ 嵌入、没走那条入口,指针一直是 NULL,strlen(NULL) 就是 SIGSEGV。启动前调一次 cokit_initialize()(XOfficeNative.ensureAndroidKitBootstrap), 引擎侧对空指针做了判空,见 native-src/android/patches/0002-engine-android-documentload-no-abort.patch。

closeDocument() 不能在 UI 线程上等 COOLWSD ​

closeDocument() 要等 COOLWSD::run() 返回(g_coolwsdRunningMutex 被 app 线程整段持有), 拿到锁才说明 kit 的 ~Document() 跑完了,兜底的 DocumentData::deallocate() 才排在它后面—— 那张全局 map 没有锁,顺序是唯一的保护,所以这个等待不能省。

但不能在调用线程上等。这个函数是从 UI 线程调进来的,文档没加载成功时 run() 可能迟迟不返回, 鸿蒙看门狗 6 秒就判 THREAD_BLOCK 把进程杀掉(THREAD_BLOCK_3S → THREAD_BLOCK_6S → processExit:1)。现场表现是「选完文件就闪退」,日志里只有一条 appfreeze, 完全看不出是卡在这个 lock 上。所以等待整段挪到 doc_teardown 线程。

docId 必须在 closeDocument() 里取值传给后台线程,不能让它去读 g_appDocId: 等待期间用户完全可能又开了下一篇文档,那时全局里已是新编号,回收会打到活着的文档上。

一个进程只有一个 COOLWSD,run() 永不返回(三端都一样) ​

这是整条编辑链路最根本的形态约束,弄错了症状会在很远的地方冒出来。

common/Common.hpp 里我们把 X_OFFICE_APP 放在了 DOCS_SHARE_PROCESS 1 那一侧 (和 iOS / MACOS / QTAPP 同侧),而 build_mobileapp.sh 三端都开 -DX_OFFICE_APP=1。 所以三端全是共享进程形态:一个 kit 进程同时托管多篇文档,靠 mobileAppDocId 区分。 它必须留在这一侧,因为它和 runKitLoopInAThread() 是一套的(那一侧的形态里引擎实例由 宿主提前建好、主循环单开线程跑)。

这个形态的直接后果是 COOLWSD::run() 不会返回:COOLWSD.cpp:4180 那个主循环的 退出条件是 SigUtil::getShutdownRequestFlag(),而它在这个形态下恒为 false —— 上游在同文件 4255 行专门注明了「always returns false on iOS, thus the above while loop never exits」,后面整段收尾代码都被 #if !defined(IOS) 挡掉。

所以对照写法只有一种是对的:

上游 Android(DOCS_SHARE_PROCESS=0)我们三端 = 上游 iOS(=1)
COOLWSDwhile(true){ new COOLWSD; run(); },一轮服务一篇文档new COOLWSD; run(); 一次,run() 之后上游直接 assert(false)
客户端 fd每轮循环开头建一个每篇文档建一个(CODocument.mm:68),连同一个长命监听 socket
关文档靠 run() 返回收尾只关通知管道一端,转发线程醒来后关掉 closeFd 与 clientFd

照 Android 那套写会踩两个连续的坑,而且两个都极难从现场反推:

  1. closeDocument() 若等 run() 返回,那就是永久等待。在 UI 线程上等 → 鸿蒙看门狗 6 秒判 THREAD_BLOCK 杀进程,现场是「选完文件就闪退」,日志里只有一条 appfreeze。
  2. 把等待挪到后台线程之后,闪退没了,但循环第二轮永远不会开始 → 客户端 fd 与 监听 fd 都不会更新。现场是「第一篇文档能开,之后任何文档都停在『正在打开』, 退出页面重进也没用,只有杀掉 App 才恢复」。

DocumentData::deallocate() 也不要在宿主侧兜底:进程内没有安全的时机去等 kit 的 ~Document()(kit/Kit.cpp:898)跑完,上游 iOS 同样把自己那句注释掉了 (DocumentViewController.mm:611)。

另外 runApp2JsThread() 的两个 fd 必须按值捕获。全局里那两份随时会被下一篇文档 换掉,而上一条转发线程可能还没退出,读全局会 poll 到别人的 socket 上去。

前端消息必须过一条泵,HULLO 不能在 UI 线程上等连接 ​

上一条把 closeDocument() 的等待挪走之后,顺带把原本靠它撑着的一个隐式保证也拆了: 「上一篇文档彻底收摊之后才会开下一篇」。于是开完一篇再开另一篇必然崩,故障线程叫 accept_poll,断言是

Assertion failed: pair2.fd[0] == pair.connectingFd (net/FakeSocket.cpp: fakeSocketAccept4: 559)

根因是客户端 fd 建错了地方:当时它跟着 app 线程的循环,在每轮 run() 开头才新建。 重开文档时全局里那个 fd 还是上一篇文档那个已经连过的,HULLO 拿它再 fakeSocketConnect() 一次,accept 侧就撞上上面那条断言。

正确的归属见上一条:客户端 fd 属于文档,在 openDocument() 里建, closeDocument() 里置 -1。awaitServerSocket() 只负责等那个一次性的监听 socket (前端可能比 app 线程更早跑到 HULLO,那一刻直接返回失败就是「第一篇文档偶发打不开」)。

而这个等待不能在 UI 线程上做,否则就是上一条那个看门狗坑的翻版——注意 fakeSocketConnect() 本身就是阻塞的,原来它就跑在 UI 线程上,属于同一类隐患。 所以 fromFrontend() 拆成两段:UI 线程只把消息塞进 g_inbound, 真正的处理在 js2app 线程上(handleFrontendMessage()),那里允许阻塞。

泵线程里每条消息都包了 try/catch:这条线程死掉整个编辑会话就没人喂了, 而且异常穿出 std::thread 是 terminate。openDocument() 里要清 g_inbound—— 上一篇文档没处理完的消息带的 view id 与编号都是旧的,发过去只会换回一堆 error。

会话身份必须整份读写,不能拆成几个全局各自读 ​

现场是「反复多开几次文档就闪退」,开的次数越多越容易撞上:

Assertion failed: _mobileAppDocId > 0 && "Unexpected to have no mobileAppDocId in a mobile app"
    (wsd/DocumentBroker.cpp: DocumentBroker: 279)
Tid:13238, Name:websrv_poll
    ... ClientRequestDispatcher::handleIncomingMessage -> StreamSocket::handlePoll -> SocketPoll::poll

一篇文档的身份是三样东西:URL、mobileAppDocId、fakeClientFd。上游把它们挂在文档 对象上(ios/Mobile/CODocument.mm 的 fakeClientFd / appDocId / copyFileURL), 天然不会撕裂。我们一次只显示一篇,于是退化成进程级状态 —— 那就必须自己当成一个整体。

拆成三个全局各自读就会这样:HULLO 在 js2app 线程上处理,先读 fd,接着 awaitServerSocket() 与 fakeSocketConnect() 都可能阻塞,最后才去读 URL 与编号 拼握手串。这中间 UI 线程一旦 closeDocument() 把三个值清零,拼出来的载荷就成了 " 0", 接收侧(wsd/ClientRequestDispatcher.cpp:902 按空格切)解出编号 0,然后 abort。

所以收成一个 DocSession,配一把 g_docMutex:openDocument() 在锁里整份立起来, closeDocument() 在锁里整份取下并清空,HULLO 一进来就取一次快照,之后只认副本。 连接完成、发布管道之前还要再确认一次「这篇文档还是当前那篇」——连接期间用户完全可能 已经换了文档,那时这套 fd 谁都不认领(app2js 还没起),得自己收干净。

fd 的归属也一并理清,三者各有唯一的关闭方:

fd谁关
clientFdHULLO 持有,起了 app2js 就移交给它;HULLO 中途放弃时自己关
closePipe[0]closeDocument()(那次关闭本身就是唤醒信号)
closePipe[1]app2js 线程

closeDocument() 不能碰 clientFd:那一刻 app2js 可能正 poll 着它,而 FakeSocket 对已关闭的 fd 是 assert 而不是返回错误。代价是「HULLO 还没来就关掉文档」会漏一个 fd 槽位, 这个上游同样存在(CODocument 在 init 里就分配了 fakeClientFd),量级可以忽略。

runApp2JsThread() 的两个 fd 也必须按值捕获,理由同上:全局里那份随时会被下一篇 文档换掉,而上一条转发线程可能还没退出,读全局会 poll 到别人的 socket 上去。

同一条断言还有第二个成因:握手串必须是首帧 ​

上面那套改完之后,「反复多开几次就闪退」又复现了同一条断言,栈也一模一样。 符号化确认路径是 handleIncomingMessage → handleClientWsUpgrade → RequestVettingStation::handleRequest → createDocBroker,也就是握手那一帧,编号仍然解成了 0 —— 但这次宿主拼串的地方明明 是对的(session.valid() 已经拦掉 docId == 0 和空 URL)。

问题在 FakeSocket 的写语义:fakeSocketWrite() 只检查 fd 存不存在、有没有 shutdown, 不检查连没连上(net/FakeSocket.cpp:693)。往一个刚 fakeSocketSocket() 出来、 还没 fakeSocketConnect() 的 fd 写,会直接把数据 emplace_back 进对端读缓冲, 一次写就是一帧。于是抢在握手前面写进去的消息,会成为 COOLWSD 读到的首帧。

而 MOBILEAPP 形态下首帧是按 "<url> <appDocId>" 解的: clientvisiblearea x=0 y=0 ... 这种消息有空格,于是不会落进「没给编号」那一支, 而是把第二个 token 当编号去解 —— x=0 解不出数,编号取 0,随后 abort。

抢跑的是上一篇文档的尾巴消息:closeDocument() 之后 openDocument() 立刻把 clientFd 换成新的未连接 fd,而旧页面排在 g_inbound 里的消息还没处理完, 转发时读到的已经是新 fd 了。所以开的次数越多越容易撞上。

修法是给 DocSession 加一个 connected:握手串写出去之后才置位(置位时同样要确认 「还是当前那篇」),转发路径见到未置位就丢消息并记一条 handshake not sent yet, dropped。 会落到这里的都是上一篇文档的尾巴,对新文档没有意义。

顺带记一下排查手段:崩溃日志里 libx_office_u.so 的帧只有导出符号能符号化, DocumentBroker 这些都在静态库里、又没编 -g,设备侧解不出来。用本地 .so 的 .dynsym 按地址反查即可(llvm-nm --defined-only -S --numeric-sort,找 addr <= pc < addr + size 的那个符号),这一步是分辨「HULLO 路径」还是 「Proxy / Batch 路径」的唯一依据 —— 后者在 ClientRequestDispatcher.cpp:2770 与 DocumentBroker.hpp:292 是显式传 0 的,光看断言文本会往那边猜错。

文档 URL 必须完整 percent-encode,只挑几个特殊字符换不行 ​

现场是「文档外壳加载出来了,内容一直不显示,也不报错」,而且只有中文文件名才复现:

coolwsd: ERR [ lokit_runloop ] ERR  Exception while loading url
    [file:///…/inbox/1/%25E4%25B8%25AA%25E4%25BA%25BA…docx] for session [001]: std::bad_alloc
    kit/Kit.cpp:1428
coolwsd: ERR  ToMaster-001: Failed to get LoKitDocument instance for […]
    kit/ChildSession.cpp:1127

%25 是 % 的编码,所以这串是二次编码:引擎在找一个名字里带字面百分号的文件。 (同一轮日志里前端那条 socket URL 是正常的一次编码 %E4%B8%AA,可以对照着看。)

原因是 coolFileUrl() 当初只转义了 % 空格 # ? & + 六个字符,中文原样留着。 但 COOL 这条链的约定是入参已经编码好:

  • wsd/ClientRequestDispatcher.cpp:908 写着「URLs are percent-encoded,所以空格可以 可靠地分隔 token」——握手载荷 "<url> <appDocId>" 就靠这个切;
  • 上游 Android 送 mTempFile.toURI().toString()(LOActivity.java:470), java.io.File.toURI() 会把非 ASCII 编成 UTF-8 的 %XX;
  • 上游 iOS 送 NSURL.absoluteString,同样是编好的。

递进去生 UTF-8,下游就按「已编码」的前提又编一轮。改成逐段 encodeURIComponent() 后 拼回 /(整串编会把 / 也变成 %2F,路径就断了),段内的 % # ? & + 空格 与非 ASCII 一并交给它。

纯 ASCII 文件名下两种写法结果完全一样,所以这个坑在早期的测试文件上一直没暴露。

独立的 l10n-all.js 不能往 cool.html 里补,它已经在 bundle.js 里了 ​

browser/Makefile.am:1477 在 ENABLE_MOBILEAPP 下 --exclude='l10n-all.js',不是漏了: browser/release/COOL_JS.m4 的第一行就是它,也就是说它是 bundle.js 的第一段 (bundle.js 开头那句 var onlylang=window.LANG,hyphen=onlylang.indexOf("-") 就是它的 压缩版)。独立文件只是给服务端按语言单独请求用的。

所以别往 cool.html 里加 <script src="l10n-all.js"> —— 那会让同一份字典表在 bundle.js 之前再跑一遍,白搭 7M 未压缩 JS 的同步解析,stage_cool_dist.sh 里直接 rm 掉。

真正决定语言的只有 window.LANG:由 ?lang= 经 js/global.js:2322 设出(那行还要求 window.ThisIsAMobileApp,它来自 cool.html 里 init-app-type=mobile → MobileAppInitializer)。 l10n-all.js 的中文分支只认 zh-CN / zh_CN / zh-Hans-CN / zh_Hans_CN 四种写法, 而鸿蒙给的是 zh-Hans,所以宿主侧要过 coolLanguageTag() 归一。

握手接不上要往宿主抛一条,否则就是「一直转圈,不报错」 ​

officeLastEngineError() 只在初始化时读一次,open() 之后引擎侧再出问题就没人读它了。 HULLO 那几条失败路径(COOLWSD 没起监听、没有活着的会话、fakeSocketConnect 失败、 连接期间文档被换掉)原先只写 hilog,用户那边看到的就是文档永远停在「正在打开」且毫无提示。 现在这几处都补了 xoffice::recordEngineError()。

界面中文:App 形态只认 window.LOCALIZATIONS,l10n/*.json 那条路是给服务端的 ​

菜单全是英文时,别去翻 l10n/ 下的文件,那条路在 App 形态压根不走。

js/global.js:2084 的 String.prototype.toLocaleString 在 ThisIsAMobileApp 分支里 只查 window.LOCALIZATIONS,查不到就原样返回英文原文。而 cool.html 里那三个 <link rel="localizations"> 指向的 l10n/localizations.json,值是 %SERVICE_ROOT%/browser/%VERSION%/l10n/ui-zh_CN.json —— 两个占位符由 coolwsd 在响应里 替换,我们是 file:// 直接加载,没人替换;而且 ui-*.json 那批文件压根没生成 (browser/Makefile.am:31 的 L10N_LANGS 来自 engine/distro-configs/Langs.conf)。

真正生效的是 l10n-all.js:它按 window.LANG 挑好字典设进 window.LOCALIZATIONS。 但不用去引它 —— 它已经是 bundle.js 的第一段,详见上面那节。stage_cool_dist.sh 只需把独立的那份 7.1MB 删掉。

于是唯一要做的就是把 window.LANG 给对,而坑在语言标签本身。l10n-all.js 对带地区的语言是逐个字面量比对的 (browser/util/create-l10n-all-js.py 的 script_variants),中文那组只列了 zh-CN / zh_CN / zh-Hans-CN / zh_Hans_CN,裸的 zh-Hans 不在其中。 而鸿蒙 i18n.System.getSystemLanguage() 恰好只给 zh-Hans(安卓给 zh-CN、 iOS 给 zh-Hans-CN,本来就能匹配)。所以三端统一过一道 coolLanguageTag()。

引擎自己产生的那批字符串(UNO 命令标签、侧边栏)是另一层,要 engine/translations 仓在场并带 --with-lang=zh-CN 重编引擎才有 —— 我们这次的构建日志里明确回退了: --with-lang is not supported without the translations repo checked out ... falling back to en-US。 COOL 自己的界面串(菜单、工具栏、对话框)不受这个影响,走上面那条就够。

(已推翻)同一个 lang 不能既喂前端又喂引擎,引擎那份得掐掉 ​

上面把 window.LANG 修对之后,出现了一个乍看无关的回归:文档外壳正常加载,内容区 永远空白,也不报错。纯 ASCII 文件名一样复现,所以跟路径、跟 percent-encode 都没关系。 引擎侧的真实现场是:

ERR Exception while loading url [...]: std::bad_alloc     kit/Kit.cpp:1428
ERR Failed to get LoKitDocument instance for [...]        kit/ChildSession.cpp:1127

根因是 ?lang= 这一个值被两条完全不同的链路共用:

消费方路径我们要不要
前端界面翻译window.LANG → l10n-all.js → window.LOCALIZATIONS要
引擎文档语言js/global.js:2340 塞进 load url=… lang=… → COOLWSD → documentLoadWithOptions 的 Language=(kit/Kit.cpp:2192)不能要

第二条在我们这个引擎上是有害的:给出一个 LibreOffice 认得的中文标签,它会真去切 CJK 语言环境,而引擎是在没有 engine/translations 仓的情况下编的(就是上一节那条 falling back to en-US),那条路上直接抛 std::bad_alloc。

早先没暴露纯属巧合 —— 鸿蒙原始标签 zh-Hans 是个 LibreOffice 认不出的裸标签,它跳过了; 归一成 zh-CN 之后才真正生效。所以这个 bug 是「把语言标签修对」顺带引出来的。

当时的处理办法是在宿主层把 load 消息里的 lang=… 摘掉,前端那份 window.LANG 保持不动。

这条已经被真机复验推翻,代码也撤回了。 日志确认改写确实生效(forwarding without lang: 那行在场,转给引擎的消息里没有 lang=),但 std::bad_alloc 一模一样地复现。 所以 Language= 不是原因,摘掉它只是白丢引擎侧的文档默认语言。

留着这一节是因为推理链本身没错、只是结论错了 —— 别再照它走第二遍。真正的原因当时还没找到, 诊断手段见下节的 onAllocationFailed() 探针。

落地路径不能复用,COOLWSD 的 docKey 就是文件路径 ​

现场是「第一次能开,退出去重开同一篇就 open:fail LOADFAIL cmd=load kind=docunloading」, 引擎日志里写得很直白:

WRN  Document […] has been modified behind our back. Informing all clients.
     Expected: …T04:49:57.070406Z, Actual: …T04:50:30.370886Z
WRN  The document […] could not be uploaded to storage because there is a newer
     version there, and no active clients exist to resolve the conflict. Stopping.
INF  Finished polling doc […]. stop: true, CloseReason: [conflict]
INF  Rejecting loading session [002] with error: cmd=load kind=docunloading

docKey 是从 URI 推出来的(wsd/RequestDetails.cpp),也就是文件路径本身。 鸿蒙侧必须把选择器给的授权 URI 拷进沙箱才能给引擎,而原先每个 editor 实例固定用 inbox/<sessionKey>/<文件名> 这一条路径 —— 重开就是同一个 docKey,而上一篇的 DocumentBroker 未必已经消失。重新拷贝改掉了 mtime,broker 一比时间戳就判「文件被人 在背后改了」,走冲突处理直接把自己停掉,新会话跟着被拒。

办法是每次打开多套一层子目录(stageDocument()),对引擎就是一篇全新文档,撞不上任何 残留 broker。旧目录顺手递归删掉,cacheDir 不会长 —— 引擎可能还持着上一份的 fd, 但 POSIX 下 unlink 不影响已打开的 fd。

子目录名必须带时间戳,不能只用进程内计数器。 第一版用的是 inbox/<handle>/<_stageSeq>, _stageSeq 是模块级变量,应用重启就归零,而 cacheDir 是跨进程存活的 —— 于是重启后第一次 打开就撞上上一进程留下的同名目录。鸿蒙的 fileIo.mkdirSync(path, true) 即便 recursion=true, 撞已存在的路径也是直接抛 EEXIST(同一个坑见 XOfficeResources.ets 的 ensureDir),表现成 「重启后一篇都打不开,只报 open:fail cannot copy the picked document into the app sandbox」。 现在是 inbox/<handle>/<Date.now()>-<seq>,外加 accessSync 护栏。

顺带:这条报错以前不带原因,现场只剩一句「拷不进沙箱」。而这一步能失败的原因至少有三类 (目录已存在、授权 URI 已过期、空间不足),光看那句话分不出来,所以 stageFailedMessage() 现在会把底层异常拼在后面 —— hilog 缓冲十几分钟就滚掉,事后基本捞不回来。

安卓与 iOS 不落地(选择器直接给真实路径,mtime 不变),所以这条是鸿蒙专属。

ohpm 报 zlib: unexpected end of file:先查边车文件,再怀疑拷贝竞态 ​

ohpm ERROR: Fetch local package: ".../libs/x-office-native.har" failed.
            ZlibError: zlib: unexpected end of file
ohpm ERROR: Run install command failed
Original Error: readPkgJson failed!

首要成因是 AppleDouble 边车文件,而且它一度骗过了这里的自检。

仓库放在 exFAT 卷上(/Volumes/app),exFAT 存不了扩展属性,于是 macOS 给每个带 xattr 的条目在旁边写一个 ._xxx。这些文件 stat 报 4096 字节,读却返回 0 字节。 tar 打包时因此报 Truncated input file (needed 4096 bytes, only 0 available) 并写出一个损坏的成员,解包走到它就 Truncated tar archive 非零退出 —— 真实内容 其实全在包里,但 ohpm 拿到的是一个解不到底的包。COPYFILE_DISABLE=1 只管住 tar 自己不去生成 ._,管不住 cp 已经落在盘上的那些。

所以 build_oh.sh 打包前会先 find "$HAR_STAGE" -name '._*' -delete; stage_cool_dist.sh 在开头和结尾各清一次(结尾那次是必须的:往 global.js 追加静音代码那一步会让 macOS 重新生成一个 ._global.js)。

这个坑不止鸿蒙:iOS 那边前端与引擎资源是 cp -R / rsync 进 utssdk/app-ios/Resources/ 的,拷完会新生成一批(实测 4865 个,每个 4096 字节, 约 20MB),会原样进 app bundle,所以 build_ios.sh 末尾也清一次,范围取整个 app-ios —— 产物自身的边车(Frameworks/._XOfficeCore.xcframework)落在父目录里。 安卓不受影响:aar 由 gradle 打 zip,实测包内 0 个。 仓库层面另外在 .gitignore 里加了 ._*,免得它们混进提交。

自检也换掉了。原来是:

bash
gzip -t <har>                                            # 只验外层 gzip 流

gzip -t 验不出 tar 层的坏成员 —— gzip 流完好、解包却走不到底,正是上面那种, 于是它给出假 OK 放行了坏包。现在改成把整份清单读到底并真的看 tar 的退出码:

bash
tar -tzf <har> > /dev/null    # 走不到底就非零退出

清单另存成文件再 grep -cx 'package/oh-package.json5',不接管道:脚本开了 set -o pipefail,grep -q 找到就退出会让 tar 吃到 SIGPIPE 返回 141,整条管道 被判成失败 —— 包完全是好的,却报「har 里缺 oh-package.json5」。

排掉边车文件之后,还剩一种真的是竞态:这个 har 有 110MB 上下,HBuilderX 每次运行 都要把它整份拷进 unpackage/dist/dev/app-harmony/uni_modules/x-office-u/utssdk/app-harmony/libs/, 而 ohpm 偶尔会在拷完之前就去解它。这一种重跑一次就好。判断方法是在源包上跑一遍 上面那条 tar -tzf:源包能解到底就是拷贝竞态。

顺带一提,unzip -l <har> 一定会报 End-of-central-directory signature not found,那是正常的:har 是 tar+gzip,不是 zip。 别把这条当成包坏了的证据(我就为此绕过一轮)。

日志里的文档 URL 是双重编码的,那是上游的显示变体,不是 bug ​

排「文档打不开」时会看到同一篇文档在日志里有两副面孔:

INF  SHA1 for DocKey [...] of [/data/.../个人租房合同.docx]                    ← 解码后的真实路径
INF  Document ctor for [...] url [/data/.../个人租房合同.docx]                 ← 同上
INF  Loading url [file:///.../%25E4%25B8%25AA%25E4%25BA%25BA....docx]        ← 双重编码
ERR  Exception while loading url [file:///.../%25E4%25B8%25AA....docx]: ...   ← 双重编码

后两条打的是 getJailedFilePathAnonym(),也就是 _uriJailedAnonym。它在 wsd/DocumentBroker.cpp:1442-1445 被编码了两次:先 Poco::URI::encode(localPath, "#?"), 再套一层 Poco::URI(Poco::Path(...))。真正传给 documentLoadWithOptions() 的是 getJailedFilePath()(_uriJailed),只编码一次,是对的。

所以别照着这两行去追编码问题,那是死路。要确认引擎实际收到什么,看 SHA1 for DocKey ... of [...] 那行的路径,或者在宿主侧把 load 消息打出来。

默认别开调试日志,那是卡顿的主要来源 ​

Log::initialize() 的级别、以及 SAL_LOG,两个都默认压到很低,排障时才用环境变量开:

环境变量默认作用
X_OFFICE_LOG_LEVELinformationCOOLWSD / Kit 的 LOG_*
X_OFFICE_SAL_LOG不开引擎自己的 SAL_*,写到 <root>/x-office-sal.log(当前构建下无效,见下节)

COOLWSD 那档别降到 warning:那一档把「引擎请求加载哪篇文档」这类每篇一条的关键线索 也一起关了。information 是按文档 / 会话打的,不按 tile,基本不花钱。

引擎的 SAL_INFO / SAL_WARN 在本构建里是编译期消掉的,别指望它 ​

bash
$ grep -n 'ENABLE_DEBUG\|ENABLE_SAL_LOG' engine/config_host.mk
174:export ENABLE_DEBUG=
219:export ENABLE_SAL_LOG=

两个都是空的,于是 SAL_INFO / SAL_WARN 全部展开成空操作。给 SAL_LOG / SAL_LOG_FILE 设任何值都不会有一行输出,日志文件根本不会被创建 —— 别把「没有日志文件」 当成「代码没走到」。要真拿到引擎日志,只能带 --enable-sal-log 重编整个引擎(几小时)。

所以看引擎内部得靠进程内探针。现成两个:

探针在哪机制抓什么
onAllocationFailed()XOfficeMobileApp.cppstd::set_new_handleroperator new 失败的现场
__wrap___cxa_throw()engine/ohos/Bootstrap/xoffice-throw-probe.cxx链接期 -Wl,--wrap指定异常类型的抛出点

两个都自己走 _Unwind_Backtrace + dladdr 打栈(backtrace(3) 不能用,OHOS 的 musl libc 没有它),报完就把控制权原样交回去,不改变失败行为。new_handler 那个成立在 「引擎与本桥的 DT_NEEDED 里是同一份 libc++_shared.so」上,所以全进程共享。

抛出点探针踩过一个坑,值得记下来。

抓抛出点只能用链接期 --wrap,不能在插件 .so 里覆盖 __cxa_throw ​

第一版是在 libx_office_u.so 里直接定义一个 __cxa_throw 覆盖掉 libc++ 的,再用 dlsym(RTLD_NEXT, "__cxa_throw") 找回真身转发。理论上可行(Linux/Android 上是标准做法), 在鸿蒙上会把整个应用打死:

Reason:Signal:SIGABRT(SI_TKILL)   Tid:5672, Name:lokit_runloop
#02 libx_office_u.so(__cxa_throw+148)
#03 liblo-native-code.so  ← ModuleManager::identify()

dlsym(RTLD_NEXT, …) 在 OHOS 的 ld-musl(带命名空间)上返回空,于是兜底那句 abort() 生效。而且它触发在引擎启动期第一个异常上 —— 那个异常是 JobExecutor::notifyEvent() 里 ModuleManager::identify() 抛的、上游自己 catch 掉的, 本来完全无害。也就是说:覆盖 __cxa_throw 一旦找不回真身,任何一个正常异常都会变成崩溃, 而这个前提没法在设备上先验证(/data/local/tmp 上 SELinux 不让跑独立可执行文件)。

改成给引擎那次链接加 -Wl,--wrap=__cxa_throw:__real___cxa_throw 由链接器在链接期 绑到 libc++_shared 的真身,运行期零查找,不存在「找不回真身」这个状态。代价是探针必须 编进引擎 .so 里 —— --wrap 只作用于本次链接的目标文件,而引擎的 throw 都在它自己那边。

验证方式(新旧 build-id 要对得上,否则你看的是旧包):

bash
llvm-nm  engine/ohos/obj/local/arm64-v8a/liblo-native-code.so | grep cxa_throw
#   U __cxa_throw            ← 真身,运行期由 libc++_shared 提供
#   t __wrap___cxa_throw     ← 探针
llvm-objdump -d … | grep -c __wrap___cxa_throw   # 16335 处 throw 已改指探针

重链只要十几秒(cd engine/ohos/source && gmake link-so),不是重编引擎。

同一套路可以照抄去抓别的:需要看谁在动某个全局状态时,装个进程内钩子比重编引擎便宜几个数量级。

std::bad_alloc 在 LibreOffice 里多半不是内存不够 ​

new_handler 探针跑完一整轮没有开火,而 std::bad_alloc 照旧抛 —— 于是可以确定它 不是 operator new 失败,是被显式 throw 的。这一步很关键,因为「bad_alloc = 内存不够」 这个直觉在这个代码库里是错的:include/rtl/ustring.hxx 里那几个带编码参数的 OUString 构造函数长这样

cpp
rtl_string2UString(&pData, value, length, encoding, convertFlags, true);
if (pData == NULL) throw std::bad_alloc();

而 rtl_string2UString() 返回空既可能是内存不够、也可能是文本编码转换失败。 OUStringBuffer::ensureCapacity()、OUString::intern() 等等同理。所以看到 bad_alloc 第一件事不是查内存,是拿抛出点的栈。

鸿蒙禁止匿名可执行内存:UNO 桥抛不出异常,文档就加载不出来 ​

上面那个「内容区一直空白、只有一句 std::bad_alloc」的现场,抓到抛出点之后是这条链 (自下往上):

text
lo_documentLoadWithOptions → Desktop::loadComponentFromURL → LoadEnv::…
  → SfxFrameLoader_Impl::load → XFrameImpl::setComponent
  → LayoutManager::implts_reset → ModuleUIConfigurationManager::createDefault
  → framework::PresetHandler::getOrCreateRootStorageUser
  → FSStorageFactory::createInstanceWithArguments
  → utl::UCBContentHelper::IsDocument → ucbhelper::Content::isDocument
  → fileaccess::BaseContent::execute → TaskManager::endTask
  → fileaccess::throw_handler              ← 文件访问出错,要抛 IOException
  → ucbhelper::cancelCommandExecution
  → cppu::throwException                   ← 正在把这个异常抛出来
  → uno2cppMapping → CppInterfaceProxy::create
  → VtableFactory::getVtables
  → VtableFactory::createVtables           ← 在这里抛 std::bad_alloc

因果链:

  1. HarmonyOS NEXT(API 12 起)禁止应用把匿名内存置为可执行,官方说法是「防止恶意应用 向匿名内存注入指令绕过代码签名管控」。Flutter 的 Dart VM 撞的是同一面墙 (mprotect failed: 22)。
  2. gcc3_linux_aarch64 这套 C++/UNO 桥要在运行期把 vtable 跳转片段的指令写进内存再 执行,靠的正是 VtableFactory 的 allocExec:mmap 匿名内存 + mprotect(PROT_EXEC)。 这一步在鸿蒙必然失败,createBlock() 于是永远返回 false,createVtables() 抛 std::bad_alloc。
  3. cppu::throwException() 是靠这个桥把 UNO 异常的 Any 转成带类型的 C++ throw 的。 于是任何经它抛出的 UNO 异常都变成 bad_alloc,原始错误信息全部丢失。
  4. 而 UCBContentHelper::IsDocument() 里是 catch (uno::Exception) { return false; } —— 「文件不存在」本来就是要被吞掉的正常情况。PresetHandler 例行探测一下 UI 配置 存储在不在,这个本该无声返回 false 的调用,把整篇文档的加载打死了:bad_alloc 不匹配 那里任何一个 catch。

所以之前查的文件名、URL 编码、语言环境、落地路径全都不是原因 —— 只要引擎内部有任何 一次 UNO 异常经 cppu::throwException() 抛出,就会撞上这个。

修法照上游给 iOS 的路子(bridges/source/cpp_uno/gcc3_ios/ios64_helper.s):运行期生成的 那 8 条指令里只有 functionIndex 与 vtableOffset 两个数在变,把所有组合在构建期摆成 一张表,vtable 槽位直接指进去。这样内存块里只剩指针,一行代码都不用生成,也就不需要 可执行内存。见 patches/0007-engine-ohos-vtable-no-exec-mem.patch:

改动位置
新增预生成片段表(1024 × 4 片、每片 20 字节,.text 增 80KB)gcc3_linux_aarch64/ohos_snippets.s
generateCodeSnippet → 查表的 ohosCodeSnippet,getBlockSize 不再为代码留位置gcc3_linux_aarch64/cpp2uno.cxx
__OHOS__ 下不再 mprotect(PROT_EXEC)shared/vtablefactory.cxx
OS=OHOS 时把 ohos_snippets 加进 bridge_asm_objectsbridges/Library_cpp_uno.mk

后两处必须同时成立:块里不放代码,才敢不要执行权限。改一处就得改另一处。

片段里用 adrp/add 而不是直接 b,因为引擎 .so 的 .text 有上百 MB,b 的 ±128MB 覆盖不住。链接后要验一下跳转目标真解析对了(链接器会把 adrp+add 收成单条 adr):

bash
llvm-nm --print-size liblo-native-code.so | grep -E "vtableSlotCall$|xofficeCodeSnippets$"
# 片段里 adr 的目标地址必须等于 vtableSlotCall 的地址
llvm-objdump -d --start-address=<片段表地址> --stop-address=<+20> liblo-native-code.so

只影响鸿蒙:安卓允许匿名可执行内存,iOS 走的就是 gcc3_ios 那套自带预生成表的桥。

预生成范围(functionIndex < 1024、vtableOffset/8 < 4)超出时返回空指针,与上游 iOS 桥 一致;真撞上了要把表调大重编。

原因是这条链路上每一片 tile、每一条前端消息都各打一条 LOG_DBG,鸿蒙这边它们还要 逐条过 OH_LOG_Print(common/Log.hpp 的 OHOS 分支)。SAL_LOG 更贵,每条都是一次 文件写,而之前默认选择器里的 +INFO.vcl.schedule 是 VCL 调度器每个定时器 tick 一条。 现场表现是「能打开也能用,但整体渲染卡、平移不跟手」。

反过来也别把 COOLWSD 降到 warning:那一档把「引擎请求加载哪篇文档」这类每篇一条的 关键线索也一起关了,上面那个空白页就是因此查了好几轮。默认落在 information ——它是按文档 / 会话打的,不按 tile,基本不花钱。

顺带记一句形态本身的成本:tile 是引擎渲染完经假 socket 送进 WebView 画布的, 不是原生视图直接绘制,所以即使日志关干净,跟手程度也比不了原生控件。 三端用的都是系统 Web 内核(鸿蒙 ArkWeb / 安卓 System WebView / iOS WKWebView), 包里没有、也不需要带浏览器内核。

另存的大小与耗时得宿主自己量 ​

编辑态的另存是引擎异步写盘的,完成信号只有一句 SAVEAS,不带大小也不带耗时。 所以 _finishSave() 里 byteSize 走 officeFileSize()(三端各自 stat), durationMs 用派发时记下的时间戳。写死 0 的话界面上就是「0kb 耗时 0ms」, 看着像保存失败。

iOS 侧那个 officeFileSize 要注意:UTS 的 number 在 iOS 上是 NSNumber, Swift 返回的 Double 必须 NSNumber(value = ...) 包一层,否则报 cannot convert return expression of type 'Double' to return type 'NSNumber'。 同一个坑在 officeCreateSession 里已经踩过一次。

另外调用方普遍会写 uni.env.CACHE_PATH + "/" + name,而 CACHE_PATH 自带尾斜杠, 于是路径里多一道 //。落盘没问题(POSIX 会折叠),但这个路径要原样回给调用方、 也要拼进 file:// URL,所以在 _runSave() 里过一道 collapseSlashes()。

中日韩字体要靠设备提供,引擎自己没有 ​

引擎随包带了 174 个字体,但全是拉丁与阿拉伯字系,一个中日韩字体都没有, 所以中文文档里每个汉字都落到 notdef,屏幕上是一排框框(拉丁字母却是正常的, 这个反差就是判断依据)。设备侧本来就有能用的:鸿蒙 /system/fonts/HarmonyOS_Sans_SC.ttf、 安卓 NotoSansCJK、iOS PingFang,缺的只是让引擎知道去哪找。

引擎那边的字体目录是 psp::getFontPath() 从 installDir 推出来的 (vcl/unx/generic/fontmanager/helper.cxx:175),外部路径塞不进去。但它底下的 fontconfig 认 getenv("FONTCONFIG_FILE")(fontconfig/src/fccfg.c:2596), 所以 setupFontConfig() 自己写一份配置指过去就行,不用改引擎也不用重编。

值得记一下当前的字体是怎么进来的:fontconfig 内置的兜底配置指向 /usr/share/fonts 与 /usr/local/var/cache/fontconfig,设备上一个都不存在, 也就是说 fontconfig 的字体目录本来是空的——那 174 个字体是 VCL 用 FcConfigAppFontAddDir() 以 app font 身份加进去的,不经过配置文件。 这也是为什么「只缺中日韩」而不是「一个字都没有」。

几个别踩的点:

  • 必须在引擎初始化之前 setenv。fontconfig 是第一次取字体列表时读这个变量的, 而那一刻发生在引擎起 VCL 的时候。
  • 配置里别写 DOCTYPE。fontconfig 不校验外部 DTD,写了反而要求 fonts.dtd 在场。
  • <cachedir> 要给一个沙箱内的可写路径,否则 fontconfig 报 No writable cache directories,每次启动都要重扫全部字体目录,首屏明显变慢。
  • <dir> 是递归扫的,给顶层目录就够;不存在的目录 fontconfig 直接跳过, 所以三端的系统字体路径可以一起列上,不必条件编译。

排障时把 SAL_LOG 的 +INFO.vcl.fonts 打开(默认选择器里已经有), PrintFontManager::initialize: collected N fonts 那个 N 只有一百多, 就说明设备的 /system/fonts 没被扫进来。

顺带一提,PDF 的字形回退是另一条路:pdfium 有自己的 POSIX 字体路径,默认指向 /usr/share/fonts 一类不存在的目录,所以 PDFiumLibrary.cxx 里单独给 __OHOS__ 传了 m_pUserFontPaths = {"/system/fonts"}。改字体相关的东西时两处都要想到。

崩溃会把用户配置写坏,所以配置目录会自愈 ​

引擎的 sofficerc 开了 SecureUserConfig=true / SecureUserConfigNumCopies=2,写了一半的 用户配置会让 configmgr 直接拒绝启动,而 Desktop 服务依赖配置提供者,跟着建不起来。也就是 一次崩溃能把之后所有打开操作永久卡住,而且报出来的现象(Desktop 服务建不起来)和真因 (上一次崩在建配置存储的过程中)完全对不上号,很容易往错的方向查。

XOfficeEngine.cpp 为此做了自愈:进引擎前在用户配置目录落一个 .x-office-init-pending,documentLoad 正常返回(成功或干净失败都算)就撤掉;下次启动 发现标记还在,就判定上次死在引擎里,清空 user/ 重来。

两个设计取舍值得留一笔:

  • 标记撤销不看成功失败,只看「有没有正常回来」。干净的失败说明进程活着、配置没被写坏, 下次没必要清;否则一份打不开的坏文档会让每次启动都白清一遍配置。
  • 用户配置存储是在加载过程中建的,之后的 initializeForRendering / getDocumentSize 不碰它,所以在 documentLoad 返回处撤标记不会漏掉真正会造成损坏的窗口。

排查时如果看到 hilog 里有 previous run died inside the engine, resetting ..., 说明这次启动清过一次配置——上一次运行是崩掉的,别把它当本次的问题。

编辑链路 ​

WebView 里的前端和引擎之间隔着一层 online 的 MOBILEAPP 代码(kit/ + wsd/,45 个源 文件)。它把平时跑在服务器上的 COOLWSD 塞进 App 进程,两端用 FakeSocket(一个纯内存的 socket 仿真)对话,因此整条链路不开任何端口、不落一次网络请求。

三端打包脚本都会先跑 common/mobileapp/build_mobileapp.sh 编出 libxofficemobileapp.a 再链进去,不需要单独执行。少了这一层插件仍然能加载,但 getRuntimeInfo().frontendReady 是 false,只剩直连 LOK 的只读预览。

上下行分两条路,不对称:

方向通道
前端 → 引擎前端调 window.COOLMessageHandler.postMobileMessage(),各端 WebView 的 JS 桥接到 native
引擎 → 前端前端向 cool://<host>/cool/.../ws/mobilesocket 发 XHR,native 拦下来吐出排队的消息

回程之所以绕 cool: 自定义 scheme 而不是直接 evaluateJavascript 灌数据,是因为瓦片是 二进制的,塞进 JS 字符串要先 base64,一次编辑几十张瓦片,开销压不住。三端各用自己的 拦截点:iOS WKURLSchemeHandler、Android shouldInterceptRequest、鸿蒙 setWebSchemeHandler + customizeSchemes。

save / saveAs / postUnoCommand / setPart 这些插件 API 在编辑态下不走 LOK 直连, 而是和前端共用同一条 FakeSocket。直连会让引擎再加载一份文档,保存下去是没编辑过的 那一份。

只能有一个 soffice_main,且必须先 startEditing 再 createSession ​

改这条链路时最容易踩的坑,两次都表现为「进页面即闪退且没有任何日志」,因为引擎的 osl 信号处理器会把 SIGSEGV 转成 Application::Abort() + _exit(1),连 tombstone 都不留。

引擎主循环(soffice_main())在本插件里只能有一条,它的归属由 SAL_KIT_OPTIONS 里的 unipoll 决定,而这个环境变量是 COKit 初始化那一刻读的:

  • 没有 unipoll:引擎自己在 lo_startmain 里起一条线程跑 soffice_main()
  • 有 unipoll:引擎不起,改由嵌入方调 runLoop() 带起来(我们的 runKitLoopInAThread())

落 unipoll 的是 setupKitEnvironment(),它在 mobileapp::start() 的第一步。所以三端 UTS 的初始化顺序是硬约束:officeStartEditing() 必须排在 officeCreateSession() 之前。反过来会两条主循环同时动 VCL 与 KitSocketPoll 的共享状态。 mobileapp::start() 里有 xoffice::engineInitialized() 前置检查兜着,顺序不对会拒绝 进编辑态并打 LOG_ERR,退化成只读直连而不是崩。

另一半是 common/Common.hpp 的 DOCS_SHARE_PROCESS,它必须和 runKitLoopInAThread() 同时开(X_OFFICE_APP 已加进那个条件,与 IOS/QTAPP/MACOS/_WIN32 并列):

DOCS_SHARE_PROCESS=1(本插件)=0(服务器 / 上游 Android)
引擎实例宿主提前建好,多文档共用lokit_main() 里现建,一文档一进程
主循环单开一条 lokit_runloop 线程lokit_main() 里 startMainLoop()
runLoop() 的 data忽略,pollCallback() 遍历 KSPolls当 KitSocketPoll* 解引用
setupKitEnvironment()嵌入方负责lokit_main() 自己调

混着用的后果:pollCallback() 拿 runKitLoopInAThread() 传的占位指针当 KitSocketPoll* 读,_document 读出栈垃圾,Document::needsQuickPoll() 里 ldr x8, [x0, #0xe0] 直接 SIGSEGV。注意 data 也不能改传 nullptr——vcl 那边 (vcl/headless/svpinst.cxx)是靠 mpPollClosure 非空决定要不要调回调的。

unipoll 一旦落下,只读直连就依赖编辑链路 ​

这是上面那条顺序约束的另一面,也是最容易误判的一处:只读直连不是独立的退路。

setupKitEnvironment() 落下 SAL_KIT_OPTIONS=unipoll 之后,引擎不再自己起 soffice_main,主循环得由 runKitLoopInAThread() 带。所以只要编辑链路走过了 setupKitEnvironment() 却没走到 runKitLoopInAThread()(进程内 COOLWSD 起不来、 中途抛异常等),引擎里就没有 Desktop / framework 服务在跑 —— 此时连只读直连的 documentLoad() 也会抛 DeploymentException。

症状极具误导性:报的是 open:fail internal engine error <com::sun::star::uno::DeploymentException>,看起来像文档或权限问题,实际根因在 几百行之前的编辑链路启动。而且 doc_initializeForRendering() 在引擎里连 try/catch 都没有(desktop/source/lib/init.cxx),里面 doc_iniUnoCommands() 要查一大批 UNO 服务,异常直接穿出 C 接口,getError() 里什么都没有。

三处防线:

  1. runKitLoopInAThread() 紧跟引擎初始化,前面不放任何会失败的步骤,成功后置 g_loopRunning
  2. openDocument() 进门先查 unipollLatched() && !mobileapp::loopRunning(),命中就直接 报「引擎主循环没在跑」并带上编辑链路记下的原因,不让它走到抛异常那一步
  3. mobileapp::start() 自己兜住异常(它是各端桥直接调的,不经过 guarded(), 往 NAPI / JNI / Swift 里扔 C++ 异常等于当场终止进程),失败原因写进 recordEngineError()

引擎起不来时怎么拿到原因 ​

officeCreateSession() 返回 0 只说明「失败」,原因分两截,都已经接出来了:

引擎自己的解释走 stderr。 lo_initialize() 末尾是这么处理 bootstrap 失败的 (desktop/source/lib/init.cxx):

cpp
catch (cpo::uno::Exception& exception)
    fprintf(stderr, "Bootstrapping exception '%s'\n", ...);

异常被吃掉,bInitialized 仍是 false,调用方只看到返回 null。而三端的 stderr 都不进 日志系统,这行话等于蒸发——iOS 更彻底,新版 macOS 连 log stream --device-name 都撤了, 真机 stderr 基本拿不到。所以 sharedOfficeHandle() 在调 kit_cpp_init() 的那一小段 窗口里用 StderrCapture 把 fd 2 接到临时文件(RAII,析构必还原),失败时把尾部 800 字节拼进错误消息。

这个原因经 lastEngineError() 回到 UTS。 三端都有: xoffice::lastEngineError() → JNI nativeLastEngineError / NAPI nativeLastEngineError / ObjC +lastEngineError → officeLastEngineError(),最后拼进 1801 的 errMsg。 所以错误直接显示在 HBuilderX 控制台,不必再去接设备日志。

引擎没留下任何 stderr 时才退回报输入(installDir / profile / CONFIGURATION_LAYERS / SAL_KIT_OPTIONS),用来区分路径问题和环境问题。

iOS 的 installDir 必须是 program/,不能是品牌根 ​

上游 iOS 官方 App 两件事叠在一起,资源摊在 bundle 根刚刚好对得上:

  1. sal/rtl/bootstrap.cxx 把 $APP_DATA_DIR 写死成 [[NSBundle mainBundle] bundlePath], 排在 getenv / rtl::Bootstrap::set 前面,插件侧设环境变量无效。
  2. lo_initialize() 只在传入 user profile 时 Bootstrap::set(BRAND_BASE_DIR, aAppURL + "/..")。官方 cok_init_2(nullptr, nullptr) 根本不走这行。

我们把资源隔离在 x-office-engine/,又传了 profile。installDir 若指向品牌根, BRAND_BASE_DIR 就变成 bundle 根,UNO_TYPES=file://$APP_DATA_DIR/udkapi.rdb 去找 Foo.app/udkapi.rdb,initialize_uno() 抛 DeploymentException。 lo_initialize() 的大 try 吃掉这次之后,外面的 officecfg::...::QuickEditing::get() 再抛一次,于是 guarded() 只能报出 sharedOfficeHandle aborted by C++ exception <DeploymentException>, 编辑链路没把 kit loop 跑起来,open() 就是 1804。

修法是三端对齐:installDir = x-office-engine/program/。lo_initialize() 那行 /.. 于是指回品牌根;program/rc 与 program/fundamentalrc 用 $ORIGIN 写 UNO_TYPES / UNO_SERVICES,不再碰 $APP_DATA_DIR。build_ios.sh 每次 打包都会重写这两份。ICU 数据仍叫 icudtNNl.dat 躺在品牌根,靠 LIBREOFFICE_ICU_DATA 指过去(官方 Xcode 工程会把它重命名成 bundle 根的 ICU.dat)。

改完要重链 XOfficeCore.framework,只换 Resources 不够——installDir() 在 framework 里。

iOS 菜单出来、画布空白:也是 SolarMutex ​

和安卓「load success、画布仍空白」同一把锁。iOS 官方在 InitVCL() 之后只 release() 一层;后面 SfxApplication::GetOrCreate / officecfg 还会再握上。 官方 App 传 cok_init_2(nullptr, nullptr),锁计数碰巧回到 0。我们传了 user profile,必须在 lo_initialize() 返回前 ReleaseSolarMutex(),见 native-src/ios/patches/0001-engine-ios-solarmutex-handoff.patch。

增量:make -C desktop -j8(在 engine-build/ios 里)再 bash ios/build_ios.sh --with-lok。只重链桥、不重编 desktop,framework 里仍是旧的 init.o。

COOLWSD 的日志:必须 Log::initialize(),鸿蒙还得自己接 hilog ​

调查「前端连上了但文档不出来」时踩到的坑:进程内 COOLWSD 与 Kit 的日志一条都没有, 不是少,是零。两个独立原因叠在一起:

一、Log-poco.cpp 的 isEnabled() 开头就是「拿不到 logger 直接 return false」, 所以 Log::initialize() 没跑过时每个级别都判死,全部 LOG_* 退化成空操作。上游三端 各自在自己的入口调了(Android 的 JNI_OnLoad、iOS 的 AppDelegate),我们是嵌进宿主 进程的、没有那个入口,于是漏掉了。现在收在 startImpl() 开头。

二、Log.hpp 的 LOG_LOG 按平台分派,__ANDROID__ 走 logcat、__EMSCRIPTEN__ 走 console,其余走 Poco 通道最终落 stderr——而鸿蒙的 stderr 既不进 hilog 也不进 HBuilderX 控制台。补了 OHOS 分支走 OH_LOG_Print(%{public}s 不能省,hilog 默认把字符串 打成 <private>)。

级别跟 NDEBUG 走,对应上游的 ENABLE_DEBUG:现在没定义 NDEBUG(online 的 assert 是活的)所以是 debug,出货打开 NDEBUG 后自动降到 information。要在出货形态下临时 开全量,用 X_OFFICE_LOG_LEVEL 覆盖。

hdc shell hilog -x -T coolwsd     # 服务端视角(COOLWSD / Kit / DocumentBroker)
hdc shell hilog -x -T x-office-u  # 我们自己的桥
adb logcat -s coolwsd -s x-office-u

服务端视角是唯一能分清「没收到 load」和「收到了但加载失败」的信息来源,白屏类问题 先看这个,别从前端日志的缺失反推。

「界面出来了、内容不显示、也不报错」按三段探针定位 ​

编辑链路的每一段失败都是静默的:前端 ProxySocket 的 XHR 只挂了 load 没挂 error, _signalErrorClose() 那几句又都是 app.console.debug(打包时静音的级别),引擎没收到 load 更不会说话。所以宿主侧在三段各留了探针(Android 见 XOfficeWebView.kt, 鸿蒙见 XOfficeHarmonyCore.ets),按出现与缺失对照:

看到停在哪
没有 frontend page ready前端页面没加载完,去看资源与页面地址
有 to engine: HULLO,没有 to engine: load url=假 socket 没 open,回程通道的问题(往下两行)
没有 cool: open -> 6B内核压根没把 cool: 交给拦截点:页面源不对(见「别改用 https 虚拟域」)或 scheme 没放行
有 cool: 404内核给的 path 与 native 侧 isMobileSocketPath() 的判定对不上
load url= 里出现 www/unifile://iOS 把 unifile://cache/... 交给了 getResourcePath,拼到 www 下面。应走 convert2AbsFullPath,控制台应有 ios path: unifile://… -> /var/mobile/…/Library/Caches/…
有 load url=,没有 from engine: LOADED引擎侧的事。先看 cool frame #N / from engine: 里有没有 error: cmd=storage / cmd=internal(这两种以前不当失败、宿主会一直「正在打开」);再看有没有 app2js exit: peer EOF(回程线程被对端关掉了)。coolwsd 标签在 Android 上经常一条都没有,别只靠它
有 LOADED,画面还是空前端渲染层。先看有没有 drop frontend BYE / POPSTATE blocked(PDF 只读被 closeapp 拆了 map);再看 PROBE 的 tiles / canvas / docLayerMap。docLayerMap=null 就是 map 已被拆,见 null.options 那一节
切文档报错 / 缩放后只能重开iOS cool: handler 对已 stop 的 XHR 又 didFinish。前端只要 load 不是 200 就关假 socket。UIManager: Button ... slide-presentation-follow 是 Writer/PDF 上的正常噪声
PDF 缩放后画布只剩「1、2」、点第二页也空叠页预览的页码底图还在,瓦片没回来。看有没有 cool: write stashed / cool XHR aborted, keep socket / tiles recover zoomend。PROBE 里 fileBased=1 parts=2 zooming=0 而 tiles=0 就是这条

engine url 那行是给第 5 种情况用的:递给引擎的串必须正好是磁盘路径编码一次的 结果,多编一次的现场就是「外壳加载出来了、内容一直不显示、也不报错」。

前端的宿主命令不能转给引擎 ​

前端有一批命令是通过 window.postMobileMessage() 发给宿主的,不是协议消息。转给 引擎的后果不是「少个功能」而是刷屏:进程内 COOLWSD 不认识它们,会回一条 error: cmd=<x> kind=unknown,前端收到就弹 AlertDialog;而 MOBILEWIZARD / hideProgressbar 是移动端面板每次开合都发的,实测一秒几十条,弹框糊满屏幕。

清单以上游 Android 的 LOActivity.beforeMessageFromWebView() 为准——那个函数返回 false 的就是宿主自己消费掉的。我们三端共用一份 C++ 宿主层,所以分派收在 XOfficeMobileApp.cpp 的 consumeHostCommand() 里,分两组:

处理命令为什么
吞掉LIGHT_SCREEN DIM_SCREEN MOBILEWIZARD EDITMODE hideProgressbar高频状态同步。而且编辑态与加载完成是从引擎回程流嗅探的,比前端自述可靠
排进宿主队列HYPERLINK PRINT SAVE downloadas exportfile REQUESTFILECOPY人手触发,频率低

两组必须分开:高频那组不能排队,宿主队列只在有出站流量时被 drain,空闲时会无界增长。 排队那组加 HOST 前缀,因为同一条队列还在传引擎的 postDirectMessage 通知,那边也有 一条 SAVE <result>,不加前缀和前端的 SAVE 分不开。

loadwithpassword 看着像宿主命令,但上游是返回 true(要转给引擎)的,别误加。

日志里那 4 条 ERR_FILE_NOT_FOUND 是正常的 ​

branding.css / branding.js / branding-mobile.css / introdocs/introdocs.js 只有商业版 才提供,社区版 browser/dist 不含。cool.html 用裸的 <link> / <script src> 引它们, 没有回退分支,404 后直接跳过。上游各端 app 同样在刷这几条。

鸿蒙侧 XOfficeHarmonyView.ets 把这 4 个降到了 console.debug,其余资源缺失仍报 console.error——不能整条降级图省事,真缺 bundle.js 那种必须一眼看见。 Android 在 logcat 里对应的是内核自己打的 E AndroidProtocolHandler: Unable to open asset URL: file:///android_asset/x-office-u/branding.css (用过 WebViewAssetLoader 的那阵子是 E WebViewAssetLoader: Error opening asset path: ..., AssetsPathHandler 内部记的 FileNotFoundException)。这几条只进 logcat、不进 HBuilderX 控制台,没去动 —— 但要记住它们不是故障线索:真出问题时它们照样只有这 4 条, 照着查会走进死胡同。

coolkitconfig.xcu 必须随包,否则 user 配置层指向空气 ​

setupKitEnvironment() 拼 CONFIGURATION_LAYERS 时,最后一层 user 配置在 非 IOS/_WIN32/MACOS 分支下取 $COOLKITCONFIG_XCU,没设就退到 file:// + COOLWSD_CONFIGDIR,而我们的 COOLWSD_CONFIGDIR 是 .,于是会拼出 相对路径 user:*file://./coolkitconfig.xcu——指向一个不存在的文件。

我们编 MOBILEAPP 层时不能定义 IOS(那会让 common/MobileApp.hpp 去 import CODocument.h,上游 iOS 的 ObjC 胶水已经被我们自己的实现替掉了),所以走不到上游 iOS 那条 ${BRAND_BASE_DIR}/coolkitconfig.xcu 分支。改法是用上游留的 COOLKITCONFIG_XCU 口子给绝对路径,在 setupKitEnvironment() 之前设好,三端同一套。

这一层不是可选装饰,coolkitconfig.xcu 带的是 COOL 的内核默认值:主题配色、CJK 排版 (竖排 / 注音 / 强调号)、默认字体、关掉 GIO UCP、关掉「句首两个大写」自动更正。

文件不在引擎 instdir 里(它是 online 侧的文件),所以两处打包脚本各自补:

端落点由谁放
Android / 鸿蒙program/coolkitconfig.xcu(installDir 就是解包后的 program/)common/pack_engine_resources.sh
iOSx-office-engine/program/coolkitconfig.xcu(品牌根也留一份)ios/build_ios.sh

前端报 null.options,真正的事故在几百毫秒前 ​

现场是双指缩放时抛:

Uncaught TypeError: Cannot read properties of null (reading 'options')
    at e._updateTileTwips (bundle.js)
    at dt.applyZoom  ->  d.zoomTo  ->  d.onMultiTouchEnd

null 的是 this._map(CanvasTileLayer._updateTileTwips() 读 this._map.options.zoom),而 docLayer._map 只有一个地方会变成 null: Layer.js 的 Map.removeLayer(),它由 L.Map.remove() 调到。也就是说 map 早就被拆了,缩放只是第一个踩到废墟的人。

拆完之后为什么看起来没事:瓦片画在 app.sectionContainer 自己的 canvas 上, 触摸也由它派发,两者都不属于被摘掉的 Leaflet mapPane。所以文档照常显示、手势照常触发, 只有摸到 docLayer._map 的代码会抛。map.remove() 里还有一句 app.socket.close(), 会话其实已经死了 —— 表现就是「文档在,但怎么都动不了」。

app.map.remove() 在 COOL 里的来源:

入口位置
closeapp 动作docdispatcher.ts —— 注意 app.map.remove() 那行在 if/else 外面,所以移动端分支给宿主发了 BYE 之后照样会拆本地 map
close: ownerterminationSocket.ts
WOPI Action_CloseMap.WOPI.js

而 closeapp 的派发源里有两类对嵌入式宿主特别危险:

  • UIManager.enterReadonlyOrClose() —— 编辑态降级为只读,已经是只读就直接 closeapp。 PDF 在 COOL 里本来就是只读,所以它没有「降级」这一档可退。 它的调用方包括 onGoBack(popStateEvent):COOL 压了 app-started 这个历史状态, 好让系统返回手势先退出移动向导,于是任何 popstate 都可能直接关掉只读文档。
  • Control.LokDialog._sendCloseWindow 与 Control.JSDialogBuilder —— 守卫相同: 文档还没 _docLoaded、且还没有任何对话框事件发给过 core(window._firstDialogHandled)时 关掉对话框,就关掉整个 app(上游注释针对的是 CSV / 宏安全警告这类加载前对话框)。

排查用 XOfficeHarmonySession.installDiagnostics():它在 onPageEnd 注入, 把 L.Map.prototype.remove、dispatcher.prototype.dispatch(只报动作名里带 close 的) 和 popstate 各包一层,经 postMobileError 回报,日志前缀分别是 MAP_REMOVE / DISPATCH / POPSTATE,都带上 docLoaded 与 firstDialogHandled。 另有 probeMapState() 在首次前端异常后回读一次 app.map 与 docLayer 的关系 (docLayerMap=null 即已被拆,other 则是 app.map 指向了旧实例)。 混淆过的调用栈读不出东西,所以判断靠这几个标志而不是栈。

注入本身是 fire-and-forget 的,没有回执就分不清「探针没开火」是真没发生、 还是根本没装上,所以那段 JS 末尾会 return "map=… dispatcher=…",宿主侧打成 x-office-u diagnostics installed map=true dispatcher=true。先确认这一行在, 再去看有没有 MAP_REMOVE。

已落地的修:缩放路径不再依赖 _map ​

上面那条链条最外层的伤害不是异常本身,而是它抛在 applyZoom 中途: map._zoom 与 docLayer._tileZoom 都已经改成新值了,视口矩形还没重建。 单窗口版式的水平居中偏移(比视口窄的文档居中、Writer 给批注留边)存在 rebuildSingleWindowView() 里,它没跑到,于是比视口窄的文档贴到左边 —— 用户看到的「PDF 放大后一平移就跳到左边、根本没法看」就是这个,和 map 被拆是同一件事。

_updateTileTwips() 要的其实只是 map 的基准 zoom 选项(一个常量), this._map 和 app.map 给出同一个数,而 Map.removeLayer() 只会把前者置空。 所以改成 const map = this._map || app.map;,缩放路径上最后一处 map 解引用就没了 (applyZoom 后半段只用 app.map;map.fire('zoomend') 也安全 —— removeLayer() 同时 off 掉了该 layer 的监听,拆掉之后不会再回调到废墟里)。

同时在回退生效时用 app.console.error('map detached before _updateTileTwips') 一次性上报(_reportedMapDetached 去重)。error 级会经 logServer 变成宿主侧的 jserror,而 postMobileError 收到非探针消息会顺手触发 probeMapState() —— 于是「谁拆的 map」这个根因,下一次复现就能连着 MAP_REMOVE 的栈一起拿到。

改的是 browser/src/layer/tile/CanvasTileLayer.js,三端共用同一份 browser/dist, 所以重建前端就三端一起生效,见下面「重建前端产物」。

调用 ​

vue
<x-office-u
  ref="office"
  :src="docPath"
  style="width:100%;height:100%;"
  @ready="onReady"
  @load="onLoad"
  @statechange="onStateChange"
  @error="onError"
  @back="onBack"
></x-office-u>
ts
import type { XOfficeDocumentInfo, XOfficeFail, XOfficeStateChange } from "@/uni_modules/x-office-u"

const office = ref<XOfficeUComponentPublicInstance | null>(null)
const docPath = ref<string>("")

function onReady() : void {
  // 引擎桥已连通,可以 open。src 非空时组件会自动打开,不必再手动调
}

function onLoad(info : XOfficeDocumentInfo) : void {
  // 只读判断的字段叫 isReadonly 不叫 readonly:readonly 是类成员修饰符关键字,
  // 作为必填字段编到鸿蒙 ArkTS 会直接语法报错
  console.log(info.kind, info.partCount, info.isReadonly)
}

function onStateChange(change : XOfficeStateChange) : void {
  // 工具栏的 撤销/重做/保存 按钮按这个置灰
  console.log(change.modified, change.canUndo, change.canRedo)
}

function onError(fail : XOfficeFail) : void {
  console.error(fail.errCode, fail.errMsg)
}

function onBack() : void {
  // 前端顶栏返回键(含系统返回手势)被点击。前端不会自行切只读或关文档,
  // 怎么返回由页面决定:典型做法是有未保存修改先 save() 再 navigateBack
  uni.navigateBack()
}

// 原地保存
office.value?.save()

// 另存为 docx
office.value?.saveAs({
  path: "/storage/emulated/0/Download/out.docx",
  success: (res) => { console.log(res.path, res.byteSize) }
})

// 导出 PDF
office.value?.exportPdf({ path: "/storage/emulated/0/Download/out.pdf" })

// OCR / 纯文本落成 Word(只支持 doc / docx;Web 端不支持)
office.value?.createFromTextByDoc({
  text: "识别结果\n第二段",
  path: uni.env.CACHE_PATH + "ocr.docx",
  format: "docx"
})

// 没被组件包装的能力直接下 UNO 命令
office.value?.postUnoCommand(".uno:Bold", "")

// 移动端打开后先是只读展示;右下角铅笔已隐藏,用这个进入编辑
office.value?.enterEditMode()

参数 ​

字段说明默认
src文档路径。非空时 @ready 后自动打开""
password打开密码,加密文档需要""
readonly只读打开,屏蔽全部编辑入口false

事件 ​

事件负载说明
ready—引擎桥连通、可以 open
loadXOfficeDocumentInfo文档打开成功
statechangeXOfficeStateChangemodified / canUndo / canRedo / currentPart 变化
errorXOfficeFail引擎初始化、打开或保存失败
back—前端顶栏返回键(含系统返回手势)被点击。前端不自行切只读 / 关文档,由外层页面决定如何返回(App 三端;web 端无顶栏,不会触发)

顶栏右上角的汉堡主菜单按钮已隐藏(宿主自绘菜单);返回键行为见 back 事件。 右下角进入编辑的铅笔悬浮按钮也已隐藏,改由 ref.enterEditMode() 进入编辑态 (移动端打开后仍先是只读展示,只是不再露出那颗 FAB)。 编辑态左上角的打勾按钮 = 退出编辑回到只读展示 + 宿主自动原地保存(写回 open() 传入的原始路径,见下文「工作副本」;失败走 error 事件,成功后 statechange 的 modified 变 false)。 这些都是前端(browser/src)与宿主改动,重建前端产物后三端一起生效。 enterEditMode() 本身走 WebView 注入,不重建前端也能用。

方法 ​

方法说明
open(options)打开文档。options.path 必填
save(options?)原地保存回原路径原格式(写回 open() 传入的原始文件,见「工作副本」)
saveAs(options)另存为其他路径 / 格式。options.path 必填
exportPdf(options)导出 PDF,等价于 saveAs({ format: "pdf" })
createFromTextByDoc(options)把纯文本建成 Word 并打开。只支持 doc / docx。内部先写 UTF-8 txt,引擎打开后再另存为 Word。适合 OCR 识别结果。App 三端可用,Web 端会 fail
close()关闭当前文档,组件可复用
undo() / redo()撤销 / 重做
enterEditMode()从移动端只读展示进入编辑态(原右下角铅笔 FAB)。readonly 打开时无效
setPart(index)切页 / 切工作表 / 切幻灯片
getFormatState()光标处格式状态,供业务自绘工具栏
postUnoCommand(command, args?)直连 LibreOffice 的 UNO 命令
destroy()释放。组件卸载时自动调用

open() 收什么路径 ​

引擎只能做裸 POSIX open,所以 options.path 最终必须是应用沙箱里的真实路径。系统选择 器给的不是路径而是授权 URI,两者不能混:

来源给出的东西插件怎么处理
鸿蒙 DocumentViewPickerfile://docs/storage/Users/...,仅临时授权、只能按 fd 读自动拷进 <cacheDir>/x-office-u/inbox/<handle>/ 再打开
Android x-chooseFile-s已是 cache 副本的真实路径直接用
iOS UIDocumentPicker真实路径直接用

判据是 file:// 后的 authority 段必须为空:file:///a 的第三个斜杠后就是路径, 而 file://docs/a 的 docs 是 authority。后者直接丢给引擎会得到 Unsupported URL <...>: "type detection failed" —— 字面意思是格式识别失败, 实际是路径不指向任何文件。

由此带来一个语义:这种情况下编辑的是副本,save() 也写回副本,用户原始文件不变。 这不是实现取巧 —— select() 只给读权限,写回原文件在鸿蒙上本来就做不到(要么用保存 选择器,要么申请 ohos.permission.FILE_ACCESS_PERSIST 做持久化授权)。要落到用户可见 位置得走 saveAs() / exportPdf()。

工作副本与原地保存 ​

编辑链路打开时,插件会先把文档复制到应用缓存 (<cacheDir 或 tmp>/x-office-u/work/<handle>/<时间戳-序号>/),交给引擎的是这份 工作副本:引擎的「原地保存」写回的是它自己的会话文件,而外部传入的原始路径未必 可写(只读目录、受限作用域路径等),副本则必在可写沙箱内。每次打开都是全新目录 (COOLWSD 的 docKey 就是文件路径,复用会撞上上一篇的 DocumentBroker)。

save()(不带参数)的完整链路 = 引擎写回工作副本 → 成功后宿主把副本覆盖回 原始路径 → 回调里的 path / byteSize 报告的是原始文件。写回失败会报 save:fail,不会让改动悄悄只留在缓存里。编辑态顶栏的打勾按钮触发的就是这同一条 原地保存。close() / destroy() 清理工作副本目录。

鸿蒙选择器场景例外:此时「原始路径」本身就是 inbox 副本(见上表),不再二次拷贝, 保存写回的就是该副本。

只读属性 ​

state、modified、canUndo、canRedo、currentPart。

支持的另存格式 ​

odt ott docx doc rtf txt ods xlsx xls csv odp pptx ppt odgpdf html epub。

saveAs 传了 format 但路径扩展名不匹配时,组件会按 format 补正扩展名,避免写出 扩展名与内容不符的文件。

Web 端 ​

Web 端没有 native 引擎,用的是 LibreOffice 的 wasm 产物:把 COOL 前端整包(含 soffice.wasm)部署到一个基址下,组件用 iframe 装进来。默认基址 /x-office-u/, 可用 window.X_OFFICE_WEB_BASE 覆盖。产物来源二选一:

bash
# 自己编:单仓已有 wasm/ 目录,走上游的 LibreOfficeWASM32.conf + EMSCRIPTEN_INTEL_GCC.mk
# 或者直接用 ZetaOffice 的预编译产物,省掉一次 emscripten 全量构建

必须给承载页和该基址同时下发这两个响应头,否则 SharedArrayBuffer 不可用、 wasm 线程起不来,组件会直接报 errCode: 1801 而不是等到超时:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

代价明确:约 32MB wasm + 15MB data,brotli 后约 52MB 首次下载。Safari 对 SharedArrayBuffer / wasm 线程支持较弱,实测体验明显差于 Chromium,故 package.json 里 safari 标 ×。

Web 端与 App 端的语义差异:save / saveAs / exportPdf 落点由浏览器下载决定, path 退化为建议文件名,返回的 XOfficeSaveResult.path 是文件名而非绝对路径; getFormatState() 拿不到同步状态(wasm 前端只在变化时推消息),返回默认值, 业务请改用 @statechange;createFromTextByDoc 需要可写本地路径,Web 端会 直接 fail。

许可证 ​

本插件的桥接层代码与构建脚本按仓库许可证分发。引擎侧(LibreOffice / Collabora Online) 是 MPL-2.0,对 MPL 文件的修改必须公开——third_party/collabora/online/engine 下 的 OHOS 移植补丁属于这类修改,建议直接推 Gerrit 上游,既合规又能减少长期维护负担。

更新日志 ​

未发布 ​

  • 蒸汽 Android:组件 onBeforeUnmount 经常晚于 UniPageActivity.onDestroy 或不走,destroy() 松 _element 来不及,ProxyModule.b 仍沿 XOfficeEditor._element 钉住已销毁页。组件改挂页面 onUnload;Android 再盯宿主 Activity 销毁并立刻 destroy()。运行时自己的 UniAppActivity 静态引用修不了。

  • 蒸汽 Android:libx_office_u.so 用 NDK 30 编,需要 std::__ndk1::__hash_memory。基座 pickFirst 留下的是 richtext 那份旧 libc++_shared.so,没有这个符号;进程里又已经 load 过,第二份同名库装 不进去。桥和引擎改为依赖私有 libc++_office.so(同一份 NDK 30 libc++, SONAME 改名),不再和基座抢 libc++_shared。

  • 蒸汽 Android:Debug 基座压缩 .so 后,PackageManager 常只抽出 NSS、抽不出 189MB 的 liblo-native-code.so。禁止先 loadLibrary 装一半再从 filesDir 加载引擎(Android 7+ linker 命名空间对不上)。改为:系统目录 齐了就整组从那里加载,否则从 APK 解齐到 filesDir/x-office-u/jni 再整组 System.load。蒸汽 Debug 基座会在 Manifest 写 extractNativeLibs=true 并压缩 .so,插件不能再声明 useLegacyPackaging: false,否则 AGP 9 的 packageDebug 直接失败。 报错带上真实缺失列表 / UnsatisfiedLinkError。

  • 蒸汽 Android:XOfficeEditor 的 onStateChange / onReady / onError / onBack 改为私有字段。蒸汽会给 public 函数字段生成 JS 桥并写成 editor.onReady(),可空函数在 Kotlin 里必须 ?.invoke,直接编不过。 对外仍只走 setOnXxxCallback。

  • 新增 createFromTextByDoc:把纯文本(OCR 识别结果等)建成 Word 并打开。只支持 doc / docx。内部先把 UTF-8 文本落到工作目录 .txt,引擎打开后再 saveAs 到目标路径,最后打开这份 Word, 后续 save() 写回 Word 路径。Web 端明确 fail。演示页有「从文本 创建 Word」按钮。

  • 隐藏 COOL 移动端右下角编辑悬浮按钮(#mobile-edit-button),改为 ref.enterEditMode() 进入编辑态。FAB 被藏后顶栏 PermissionMode 替代 入口一并关掉。前端 CSS / Permission.js / UIManager / MobileTopBar 已改;宿主页就绪时再注入一次样式,旧包前端也能立刻藏掉。需重建 前端后重打三端,样式注入这条不依赖重建即可生效。

  • iOS 光标回归排查包:上一轮 global.js 的触摸标志修复给兼容鼠标事件盖了 guessEmulatedFromTouch 戳,会把该事件在所有下游(mouseOnly/touchOnly 包装器、双击合成等)里的分类整体改成 touch,行为面过宽;现收窄为只保持 全局标志粘性、不改单个事件的分类(showCursor 只读全局标志)。同时在 iOS 垫片加了光标全链路诊断:点按与 500ms/2000ms 揭示节点输出 CARET 快照(触摸标志 / 光标可见位 / 引擎矩形 / 显示门条件 / cursor-handler 状态 / socket 投递状态),揭示窗口内输出 MSG(invalidatecursor 等引擎 消息到达时刻),经 jserror: 打到 HBuilderX 控制台,用于定位「点按不出 光标」断在哪一环。

  • 演示页补「另存 xlsx 到缓存目录」按钮,表格另存可测。

  • 编辑态打勾按钮改为「退出编辑 + 自动原地保存」。打勾此前只是退出编辑展示, 改动停留在引擎会话里,重开文件就是没保存;上一轮把顶栏按钮统一改抛事件后 打勾又变成无任何动作。现在前端打勾先发 XOFFICE_EDITDONE 再走原有的退出 编辑动作,宿主收到后在有改动时执行原地保存(失败走 error 事件)。

  • 编辑链路打开文档三端都改为「先落缓存工作副本」:open() 把文档复制到 <缓存>/x-office-u/work/<handle>/<时间戳-序号>/ 再交给引擎;原地 save() 的完整链路 = 引擎写回副本 → 宿主把副本覆盖回原始路径 → 回调报告原始路径, 写回失败报 save:fail,不会让改动悄悄留在缓存里。原始路径未必可写 (只读目录 / 受限作用域路径),副本必在可写沙箱。鸿蒙选择器的 inbox 副本不再二次拷贝。close / destroy 清理工作目录。

  • 安卓 / 鸿蒙打开 PDF 自动适配容器(此前只有 iOS 垫片在做):把 tryFitDrawing 移植进前端 setInitialZoom / _fitWidthZoom(_drawingFitZoom,只缩不放、 一次性、resize 时补跑),三端行为一致。

  • 修 iOS 点按落光标仍要滑一下的问题,这轮找到真正的丢帧点:垫片的序号守卫 会把乱序到达的低序号引擎帧整段丢掉——连发 drain 时多个 doSend XHR 并发, WKWebView 不保证完成顺序,proxyWrite 又是整队交换,高序号响应可能先到; invalidatecursor / cursorvisible 被丢掉后光标自然不落,滑动带来新帧才显。 守卫改为按已处理序号集合精确去重,不再跳过未处理帧。另加两道保险: 300ms 心跳 drain 兜底 native 出站通知合并标志卡死导致的帧滞留; 触摸标志的纠正从垫片冒泡阶段前移到 global.js 捕获阶段源头 (真实触摸后 2.5 秒内的兼容鼠标事件不再把 lastEventWasTouch 翻假)。

  • 前端顶栏返回键改为抛 back 事件给组件,右上角汉堡主菜单按钮隐藏。 返回键原来走 COOL 的 enterReadonlyOrClose:编辑态第一下切只读、 第二下 closeapp 拆 map,宿主只能事后收拾。现在前端把点击原样 发给宿主(XOFFICE_BACK),三端编辑器类新增 setOnBackCallback, 组件发 back 事件,返回/保存/关文档全由外层页面决定。改 Control.UIManager.ts + device-mobile.css,需重建前端并重打三端 (iOS Resources / AAR / HAR 已重打)。

  • 修三端 PDF 滑动跳变:第二次拖动时页面跳回顶/左再重新跟随。 PDF 走叠页布局 ViewLayoutFileBased,它的 viewedRectangle 是 可见页在文档空间的包围盒(pX1 恒 0、pY1 是首个可见页页顶), 而 MouseControl 的触摸平移/甩动惯性拿它当视口位置去构造 scrollTo 的绝对目标,FileBased.scrollTo 又直接把它赋给 scrollProperties——每次新手势都把视口钉回页顶/左缘。doc/excel 是单窗口布局,两值恰好相等所以没事。现对叠页布局改用逐事件的 section 局部坐标增量走 scroll(),局部坐标不含滚动偏移、跨页 也不跳。改 MouseControl.ts,需重建前端并重打三端(已重打)。

  • 修 iOS 编辑模式点文本区域光标不显示、拖一下文档才出现。 WebKit 在 touchend 后补发兼容 pointer/mouse 事件(pointerType='mouse'), global.js 的 document 捕获监听拿它把 touch.lastEventWasTouch 翻成 false;引擎的 invalidatecursor 几十到几百毫秒后才回来, showCursor 走「非触摸屏」分支把 cursor-handler 藏掉。垫片在 冒泡阶段把标志纠正回来(近 2 秒有过真实 touch 即算),并在光标 揭示窗口内强制置真。只改 iOS 垫片,重打 iOS 自定义基座即可。

  • 修 iOS 点选后要滑一下才出编辑光标。点下去位置已经进引擎,但 cursorvisible 还没到时 _updateCursorAndOverlay 会把刚画上的 光标再藏掉;滑动把那条消息带出来才显示。现点选后 3 秒内不许 藏光标,并把光标 DOM / 表格选区同步摆到点击处。只需重打 iOS 自定义基座。

  • 修上一轮 iOS 垫片启动即闪退。WKWebView 一创建就可能加载 about:blank, 垫片在 document-start 里改了全局 XMLHttpRequest,把 WebKit 自己的请求 也钩进去了。现只在 cool.html 上、且只对 cool: 回程挂钩;点选补光标 不再 fire('move')。只需重打 iOS 自定义基座。

  • 修 iOS 放大后平移/缩到多页、表格四向滚动、PPT 翻页只剩占位。根因不是 没要瓦片:滚动会 abort 还在飞的 cool: write,abort 不走 load,偶发 也是 status=0。ProxySocket 只在 load && 200 时 parseIncomingArray, 而 proxyWrite 已经把 native 队列 swap 空了,响应体在 xhr.response 里被垫片丢掉。首屏能出字,一滚就空白。现改为 abort / status=0 也把 ArrayBuffer 吃进去,重复序号跳过。只需重打 iOS 自定义基座(Swift 垫片)。

  • 修 iOS 打开 PDF 默认过大、首次切到后面页只剩序号。COOL 对绘图/PDF 不跑 _fitWidthZoom,停在 100%;切页在叠页预览里只滚动,没把新页 瓦片要回来,空页正中就只画序号。打开后按当前一页适配窗口(只缩小 不放大),setPart 后再要一遍瓦片。只改 iOS 垫片,不动三端前端, 不用重编引擎 / AAR / HAR。

  • 修 iOS 打开 PDF 后双指缩放只剩页码「1、2」、点第二页也空。PDF 在 移动端是叠页预览(fileBasedView),空页正中本来就会画页码;缩放时 cool: XHR 被 WKWebView 取消,上一轮已经不再 didFinish 以免假 socket 被关,但 proxyWrite 已经把瓦片出队,取消后数据丢了,前端 永远等不到新瓦片。现在取消而未送达的 write 先留下、下次再给;iOS 忽略 user-scalable=no,关掉 WKWebView 自己的捏合,只让 COOL 缩放; zoomend 后再要一遍瓦片和页预览。只热更 UTS,不用重编引擎。

  • 修 iOS 切文档页面报错、小文档双指缩放后不再出画。根因是 WKURLSchemeHandler:导航和缩放过会 cancel 还在飞的 cool: XHR, 我们仍对已 stop 的任务 didFinish。前端 ProxySocket 只要 load 不是 200 就 _signalErrorClose(),假 socket 死掉,只能重新打开。 已 stop 的任务不再回数据、也不再 proxyWrite(避免把新会话队列 偷走)。重开前先 stopLoading。演示文稿 follow 按钮在 Writer/PDF 上本来就没有,那两条 UIManager 日志不再当错误刷。

  • 修 iOS 引擎已 LOADED、画布仍空白。路径已经解开,kit 也回了 status:。真正拆画布的是 COOL 前端:PDF 天生只读,加载时压了 app-started 历史状态,WKWebView 的 popstate / 返回键走 enterReadonlyOrClose → closeapp → 给引擎发 BYE 并 map.remove()。 菜单(HTML 壳)还在,瓦片层被拆空。嵌入式宿主丢掉前端 BYE,拦住 app-started 的 popstate,并关掉 UI_Close 默认拆 map。另外 iOS 上 setOnStateChangeCallback 等逃逸回调补了 @UTSJS.keepAlive(日志里那句 「callback回调函数已释放」),LOADED 后会打一条 PROBE 方便对照 tiles / canvas。只热更 UTS,不用重编引擎。

  • 修 iOS 选文件后菜单在、画布空白。x-chooseFile-s 回的是 unifile://cache/x-choosefile/...,iOS 却走了 UTSiOS.getResourcePath, 把它当 www 相对路径拼成 .../www/unifile://cache/...。这条串以 / 开头, isLocalFilePath 放行,引擎 POSIX open 打不开,前端已经发出 load url=, 回程没有 status: / 瓦片。改成和 x-udp-s 一样:已存在的沙箱绝对路径 原样用,unifile:// / /static 走 convert2AbsFullPath。只热更 UTS, 不用重编引擎。

  • 修 iOS 菜单出来、画布空白。和安卓当初那次同一把锁:lokit_runloop 堵在 lo_runLoop() 门口的 SolarMutex::acquire()。iOS 官方在 InitVCL() 之后只 release() 一层,后面 SfxApplication / officecfg 还会再握上; 官方 App 不传 user profile,锁计数碰巧回到 0。我们传了 profile,没把 ReleaseSolarMutex() 抄到 iOS 上。见 native-src/ios/patches/0001-engine-ios-solarmutex-handoff.patch。 要增量重编引擎 desktop 再 build_ios.sh --with-lok,只换 framework 里的桥不够。HBuilderX 控制台现在也会打 to engine: / from engine:。

  • 修 iOS 打开文档立刻 1804:engine main loop is not running,编辑链路说 sharedOfficeHandle aborted by C++ exception <DeploymentException>。 根因是两套路径约定叠在一起。上游 iOS 官方 App 把引擎资源摊在 bundle 根, $APP_DATA_DIR 写死成 bundlePath,而且 cok_init_2(nullptr, nullptr) 根本不设 BRAND_BASE_DIR。我们把资源隔离在 x-office-engine/,又传了 user profile,于是 lo_initialize() 把 BRAND_BASE_DIR 设成 installDir/..(桌面 / Android 的 program/ 约定)——installDir 若指向品牌根, 这一步就指到 bundle 根,UNO_TYPES 去找 Foo.app/udkapi.rdb,bootstrap 失败。失败后 QuickEditing::get() 还在 lo_initialize() 的大 try 外面再抛 一次,所以看到的是异常类型而不是「Bootstrapping exception ...」。 现在 iOS 的 installDir 也指 x-office-engine/program/,program/rc 与 program/fundamentalrc 用 $ORIGIN 定位品牌根,不再依赖 $APP_DATA_DIR。 还要把 XOfficeCore.framework 重链一遍(build_ios.sh --with-lok), 只换 Resources 不够。

  • 文档里的平移 / 缩放不再带动外层页面一起滚。组件常被放进 scroll-view, 安卓原先不调 requestDisallowInterceptTouchEvent,MOVE 就被外层抢走; 现在沿祖先链禁拦截,并关掉 WebView 的 nested scroll。鸿蒙 Web 用 nestedScroll: SELF_ONLY,iOS 滑动期间暂时关掉外层 UIScrollView。

  • 修安卓打开文档闪退。SolarMutex 交出去之后 lokit_runloop 真正进了 documentLoad,5ms 后 SalAbort: Unspecified application error、进程 _exit(1)。空 SalAbort + 退出码 1 是 VCL 把 SIGSEGV 转成 Abort 的招牌。 崩在设语言:LiblangtagDataRef::setupDataPath() 对 lo_get_app_data_dir() 做 OString(),而我们没走官方 Java Bootstrap, 这个指针一直是 NULL。现在启动前调一次 cokit_initialize() 填上 data_dir / JavaVM / AssetManager;引擎侧对空指针做了判空,漏调也不再 杀进程。另外 kit 加载改走磁盘上真实存在的路径,避免 Poco 把中文文件名 编成 %25E4...。见 native-src/android/patches/0002-engine-android-documentload-no-abort.patch。 要重装整包:引擎 .so 和 libx_office_u.so 都换了。

  • 修安卓「load 已经 success、画布仍空白」。现场是 commandresult: load success: true、perm: edit、coolserver / lokitversion 都回了,然后 再也没有 status: 和瓦片。lokit_main_001 停在条件变量上是 DOCS_SHARE_PROCESS 的正常形态;真正干活的 lokit_runloop 从进 runLoop 起 CPU 就是 0 —— 堵在 lo_runLoop() 门口的 SolarMutex::acquire()。根因是我们把 Android 也改成了 iOS 生命周期 (启动时 sharedOfficeHandle(),再 runKitLoopInAThread()),而引擎 Android 的 unipoll 路径在初始化线程 InitVCL() 之后不交锁;上游 Collabora Android 同线程递归 acquire 没事。在 lo_initialize() 返回前 ReleaseSolarMutex(),见 native-src/android/patches/0001-engine-android-solarmutex-handoff.patch。 要重编 liblo-native-code.so,只重编 AAR 不够。

  • 真机抓到安卓编辑链路的第二段静默失败。file:// 落地、假 socket open、 HULLO / coolclient / load url= 都发出去了,引擎也建了 docbroker + lokit_main,但回程只来一包握手(coolserver / lokitversion / clipboardkey + 若干 progress:),随后 FakeSocket 对端 EOF,app2js 线程退出,status: 再也到不了前端,docLoaded 一直是 false,画布不建。 更糟的是会话在 download() / addSession 阶段失败时引擎回的是 error: cmd=storage kind=loadfailed 或 error: cmd=internal kind=load, 以前嗅探只认 cmd=load,这两种都不进 LOADFAIL,宿主就一直停在「正在打开」。 现在这两种都当加载失败往上抛,并在回程第一包把协议行打到 x-office-u 日志 (不依赖已经经常哑掉的 coolwsd 标签)。

  • 修安卓文档永远打不开、且一条错误都不打(日志里只有一条无关的 E WebViewAssetLoader: Error opening asset path: x-office-u/branding-mobile.css, 那是社区版必然缺的可选资源)。前端页面原先走 WebViewAssetLoader 的 https 虚拟域 https://appassets.androidplatform.net/...,而回程数据要前端自己向 cool: 发 XHR 取 —— 安全页面取非安全 scheme 属混合内容,被内核直接拦掉。之后一路静音: ProxySocket.getSessionId() 只给 XHR 挂了 load、没挂 error(js/global.js), 连 _signalErrorClose() 都走不到,socket 的 readyState 永远停在 0;而 HULLO 是走 JS 桥发的,引擎那边照常连上、日志也正常,但发 load url= 的 onopen 第一句就是 if (readyState === 1),永不成立——引擎从没收到加载请求,宿主也就等不到 LOADED, open() 的回调一个都不回。改成 file:///android_asset/x-office-u/cool.html:那是上游 LOActivity.loadDocument 用的地址,也让三端页面源统一成 file://(Origin 都是字面量 null)。同时补 setAllowUniversalAccessFromFileURLs(true) 放行跨源取 cool:,与 iOS 的 allowingReadAccessTo、鸿蒙的 setPathAllowingUniversalAccess 是同一件事。 shouldInterceptRequest 于是只剩 cool: 一条分支,assets 交回内核自己读, androidx.webkit 依赖一并去掉。

  • Android 的 onConsoleMessage 现在也放过 warning 级。混合内容被拦、跨源被拒这类内核 诊断就落在这一级,只放 error 等于把上面那个问题唯一的线索也丢掉。

  • Android 侧补上编辑链路的三段探针,鸿蒙早就有、安卓一直是全黑的。链路里每一段失败 都不报错(前端 ProxySocket 的 XHR 没挂 error、引擎没收到 load 也不会说话), 现场只剩「界面出来了、内容不显示」,光看日志分不出停在哪:

    • to engine: <消息>:前端发给引擎的头几条。有 HULLO 没 load url= 就说明假 socket 没 open,问题在回程通道;两条都在则前端已把文档交给引擎。
    • cool: open -> 6B / cool: write -> NB:回程请求真的到了 shouldInterceptRequest。 open 那次一律记(它是建立假 socket 的唯一入口),write 只记开头几条。 cool: 404 一律记 —— 落到 404 说明内核给的 path 和 native 侧的判定对不上。
    • engine has outbound data / from engine: <通知>:出站通知有没有回到宿主, LOADED 到底来没来(open() 的回调就等它)。 另外 _openForEditing 打出递给引擎的 engine url(与鸿蒙侧同一行日志,二次编码 就是这么认出来的),_initEngine 打出 editing=0/1,出站通知挂不上时补一条 editing disabled: outbound notifier rejected。
  • Android 可调试构建(FLAG_DEBUGGABLE)下打开 WebView 的 DevTools,电脑上 chrome://inspect 直接连编辑前端。上游 LOActivity 也是这么 gate 的。编辑 UI 是 几万行 JS,Network 面板能直接看到回程那条 cool: 请求成没成,比在宿主侧一层层 加日志快得多。

  • Android 编译失败:coolFileUrl 里 encodeURIComponent(parts[i]) 报 actual type is 'String?', but 'String' was expected。UTS 数组下标在 Android 上是 T | null,而 encodeURIComponent 的入参和返回值分别是 string / string | null。改成 for...of 取段、?? part 再推进 string[],与同文件 fileExtension 的三端写法一致。

  • 修「编辑链路半途失败会把只读直连一起弄坏」。现象是鸿蒙报 open:fail internal engine error <com::sun::star::uno::DeploymentException>, 看着像文档或权限问题,实际根因隔了几百行:_editing 是 false,open() 走的是只读直连。 而只读直连此时已经不能用了——officeStartEditing() 里 setupKitEnvironment() 已经落下 SAL_KIT_OPTIONS=unipoll,引擎从此不再自己起 soffice_main,主循环该由 runKitLoopInAThread() 带;编辑链路在那之后失败,就没人跑主循环,引擎里没有 Desktop / framework 服务,documentLoad() 必抛 DeploymentException。 又因为 doc_initializeForRendering() 在引擎里连 try/catch 都没有 (desktop/source/lib/init.cxx,里面 doc_iniUnoCommands() 要查一大批 UNO 服务), 异常直接穿出 C 接口,getError() 里什么都没有,最后只能以我们最外层守卫的 「internal engine error <类型名>」冒出来。三处改动: runKitLoopInAThread() 紧跟引擎初始化、前面不放任何会失败的步骤,成功后置 g_loopRunning(新增 mobileapp::loopRunning());openDocument() 进门先查 unipollLatched() && !loopRunning(),命中直接报「引擎主循环没在跑」并附上编辑链路的 原因;mobileapp::start() 自己兜住异常并把原因写进新增的 recordEngineError()—— 它是各端桥直接调的,不经过 guarded(),往 NAPI / JNI / Swift 里扔 C++ 异常 等于当场终止进程。

  • guarded() / guardedJson() 吞掉的异常现在也记进 lastEngineError()。之前只 logNative 类型名,而 startEditing() 这种返回 bool 的调用失败后调用方只看到 false,编辑链路静默退化,原因彻底丢失。三端 UTS 的 editing disabled: ... 诊断改为直接打印 officeLastEngineError(),Android 与 iOS 之前根本没有这行诊断,一并补上。

  • 引擎初始化失败现在能看到真正的原因,不再只报一句「见 native 日志」。起因是 iOS 报 1801 engine:fail COKit init failed, see native log tag x-office-u,而那句提示在 iOS 上 根本没法照做:logNative() 在非 Android/鸿蒙分支只 fprintf(stderr),iOS 的 stderr 既不进 os_log、HBuilderX 控制台也读不到,新版 macOS 又撤掉了 log stream --device-name, ios-deploy 在 Wi-Fi 配对下也看不到设备。 引擎其实自己知道原因:lo_initialize() 末尾 catch (cpo::uno::Exception&) 里 fprintf(stderr, "Bootstrapping exception '%s'\n", ...),吃掉异常、bInitialized 保持 false、只往 stderr 打一行。所以新增 StderrCapture(RAII,只在 kit_cpp_init() 那一小段 窗口接管 fd 2,析构必还原,写不进 profileDir 时退到 TMPDIR 再退到 /tmp),把引擎那行话 抓出来,经新增的 xoffice::lastEngineError() 一路接到 UTS:JNI nativeLastEngineError、 NAPI nativeLastEngineError、ObjC +lastEngineError,再由三端 officeLastEngineError() 拼进 1801 的 errMsg。引擎一句话都没留时才退回报输入(installDir / profile / CONFIGURATION_LAYERS / SAL_KIT_OPTIONS)。

  • 补上 coolkitconfig.xcu,修 user 配置层指向不存在文件的问题。setupKitEnvironment() 拼 CONFIGURATION_LAYERS 的最后一层在非 IOS/_WIN32/MACOS 分支下取 $COOLKITCONFIG_XCU, 没设就退到 file:// + COOLWSD_CONFIGDIR,而我们的 COOLWSD_CONFIGDIR 是 .,结果是 相对路径 user:*file://./coolkitconfig.xcu。我们不能定义 IOS(会让 common/MobileApp.hpp 去 import 上游那份已被替掉的 ObjC 胶水 CODocument.h), 所以走不到上游 iOS 的 ${BRAND_BASE_DIR} 分支。改法是在 setupKitEnvironment() 之前用 上游留的 COOLKITCONFIG_XCU 给出绝对路径。文件不在引擎 instdir 里,两处打包脚本各自补: Android/鸿蒙进 program/(installDir 就是解包后的 program/),iOS 进资源根。 这一层带的是 COOL 的内核默认值——主题配色、CJK 排版、默认字体、关掉 GIO UCP、 关掉句首两个大写的自动更正。

  • 修安卓进页面即闪退(无 tombstone、logcat 里只有 SalAbort 与 EXIT_SELF):我们给上游 平台链加 X_OFFICE_APP 分支时放行了 runKitLoopInAThread(),却没同时把 DOCS_SHARE_PROCESS 打开。这两个是一套的——那条分支的形态是「引擎实例由宿主提前建好、 主循环单开一条线程」,runLoop() 的 data 参数在这个形态下是个被忽略的占位(上游传的 是未初始化栈变量的地址);而 DOCS_SHARE_PROCESS=0 那侧的 pollCallback() 会把 data 当 KitSocketPoll* 解引用。于是每次 VCL yield 都在拿栈垃圾当对象读,_document 读出来 是个小整数(实测 3),Document::needsQuickPoll() 里 ldr x8, [x0, #0xe0] 当场 SIGSEGV@0xe3。引擎的 osl 信号处理器把它转成 Application::Abort() + _exit(1), 所以既没有崩溃日志也没有 tombstone。改法是把 X_OFFICE_APP 加进 common/Common.hpp 的 DOCS_SHARE_PROCESS 条件,与 IOS/QTAPP/MACOS/_WIN32 并列;同时把上游那个 未初始化栈变量换成函数内 static(vcl 侧靠 mpPollClosure 非空决定要不要调回调, 不能传 nullptr)。

  • 修同一条链上的第二个隐患:两个 soffice_main() 并发。SAL_KIT_OPTIONS 是 COKit 初始化 那一刻读的,setupKitEnvironment() 落 unipoll 必须早于初始化。三端 UTS 原先都是先 createSession() 再 startEditing(),于是引擎按非 unipoll 在 lo_startmain 里起了 一条 soffice_main,编辑链路的 runLoop() 又起第二条,两条同时动 VCL 与 KitSocketPoll 的共享状态。现在三端都改成 startEditing() 在前、createSession() 复用同一个 COKit 实例;mobileapp::start() 另加一道 xoffice::engineInitialized() 前置检查,顺序不对就拒绝进编辑态并打 LOG_ERR,宁可退化成只读直连也不带着两条主循环跑。 另外 DOCS_SHARE_PROCESS 形态下 lokit_main() 不再自己调 setupKitEnvironment() (见 kit/Kit.cpp 的 #if MOBILEAPP && !DOCS_SHARE_PROCESS),上游把这一步交给嵌入方, 这个顺序因此从「最好如此」变成了硬约束。

  • 修复鸿蒙打开任意文档必崩(SIGSEGV@0x0,栈顶 pc 0 Not mapped):aarch64 UNO 桥的 VtableFactory::flushCode() 走了 dlsym(RTLD_DEFAULT, "__clear_cache") 分支, 而 OHOS 上没有动态库导出这个符号,返回 NULL 后无判空直接调用。新增补丁 patches/0002-engine-ohos-clear-cache.patch 让 OHOS 走 __builtin___clear_cache。 详见 readme「aarch64 UNO 桥的 __clear_cache」。

  • 修鸿蒙打开任意文档必崩之二(SIGABRT,LastFatalMessage 点名 com::sun::star::ucb::InteractiveAugmentedIOException 未被捕获):aarch64 UNO 桥用 dlopen(nullptr) 的主程序域查 typeinfo,而引擎是以局部域 dlopen 进来的,查不到时 它会现造一个 type_info,而 libc++abi 判基类走指针比较,于是所有经 cppu::throwException() 抛出的 UNO 异常都变成不可捕获——连 LibreOffice 自己写在 UCBContentHelper::IsDocument() 里的 catch 都接不住。新增补丁 patches/0003-engine-ohos-uno-rtti-handle.patch 改用 dladdr + dlopen(<自身路径>, RTLD_NOLOAD) 取引擎自己的句柄。 详见 readme「UNO 异常的 RTTI 查找域」。

  • 用户配置自愈:崩在引擎里会留下写了一半的用户配置,而引擎的 sofficerc 开了 SecureUserConfig,半成品配置会让 configmgr 拒绝启动、连带 Desktop 服务建不起来—— 于是一次崩溃会把之后所有打开操作永久卡住,用户只能靠清应用数据恢复。现在进引擎 前在用户配置目录落一个 .x-office-init-pending 标记,documentLoad 正常返回就撤; 启动时发现残留标记即判定上次死在引擎里,自动清空 user/ 重来。本插件不暴露任何需要 持久化的设置,user/ 里没有值得保留的内容。三端共用。

  • 修 LOK 的 documentLoad 会让异常穿出 C 接口:lo_documentLoadWithOptions() 里 frame::Desktop::create() 写在那个大 try 之外,而它拿不到服务时抛 DeploymentException。C 接口的契约是失败返回 nullptr + 原因留在 getError(), 异常穿出去等于原因彻底丢失,桥接层只能报出一个异常类型名。新增补丁 patches/0004-engine-lok-documentload-no-throw.patch 给它单独套一层 catch 记下 Message。非鸿蒙专属,三端都受影响(Android / iOS 需各自重链引擎才生效)。

  • 桥接层三端统一加异常兜底:XOfficeEngine.cpp 的对外函数全部经 guarded() / guardedJson()(内为 catch (...),与 typeinfo 无关、永远匹配),异常类型名用 abi::__cxa_current_exception_type() 取出后写 native 日志。此后同类问题只会降级成 open:fail internal engine error <类型名>,不会再让 Node-API / JNI / Objective-C 边界外的宿主进程 std::terminate。

  • open() 接受系统选择器的授权 URI:鸿蒙 DocumentViewPicker 给的 file://docs/storage/... 只有临时授权、只能按 fd 读,引擎那边是裸 POSIX open, 之前会报与真因无关的 Unsupported URL <...>: "type detection failed"。现在鸿蒙侧 先拷进 <cacheDir>/x-office-u/inbox/<handle>/ 再打开。Android 侧 x-chooseFile-s 本就返回 cache 副本、iOS 给的是真实路径,两端只加了兜底报错。 注意:编辑的是副本,save() 也写副本,落回用户原位置需走另存。

  • 支持打开 pdf(走 Draw 的 pdfium 导入过滤器),docKindOfPath 把 pdf 归为 drawing,导出 PDF 时才会选中 draw_pdf_Export。

  • 鸿蒙编辑链路未启用时打日志说明是哪一个前置条件没过(前端未解包 / MOBILEAPP 层 不在位 / 进程内 COOLWSD 起不来),三者原先都静默退化成只读直连,无法区分。

  • build_engine_oh.sh 的补丁应用改为遍历 patches/*.patch,各自独立做幂等检查。

  • 修 Android 整包编译失败(组件本体,--uni_module 覆盖不到所以之前没暴露):

    • x-office-u.uvue 里 open / autoOpen 写在调用者之后。<script setup> 顶层函数在 Android 编成 setup() 内的局部 val fn = ::gen_fn,局部声明不能前向引用,报 Unresolved reference 'autoOpen' / 'open'。现按 open ← autoOpen ← bindEditor 排序。
    • current.save() 无参调用改为恒定传 null。契约里 save(options ?: XOfficeSaveOptions) 的 ?: 只表示可空、不生成 Kotlin 默认值,无参调用报 No value passed for parameter 'options'。

0.1.0(2026-08-28) ​

  • 首个版本。基于 LibreOffice 内核(Collabora COKit)的 Office 文档编辑视图组件。
  • 三端 API 对齐:Android / iOS / HarmonyOS 共用 utssdk/interface.uts 契约与 utssdk/libs/formats.uts 的格式推导、过滤器映射。
  • HarmonyOS:给 LibreOffice 构建系统新增 OHOS 平台(上游零支持)——新增 OHOS_AARCH64_GCC.mk / ohos.mk / CPOHOSAarch64.conf / ohos 模块, 并修正 icu / openssl / expat / argon2 / poco / pdfium 的交叉编译。
  • 打开 / 保存 / 另存 / 导出 PDF / 撤销重做 / 切 part / UNO 直连 / 光标格式状态查询。
  • 编辑前端(COOL)三端共用一份,打包前过 native-src/common/stage_cool_dist.sh 裁掉这份构建里走不到的资源(未压缩 js 源码、wasm 版专用的 l10n-all.js、 服务端管理控制台、非 en 帮助截图、模板库),49MB → 22MB。
  • XOfficeDocumentInfo 的只读字段定名 isReadonly:readonly 是类成员修饰符 关键字,作为必填字段编成 ArkTS 的 readonly!: boolean 会让 es2abc 报 10705000 语法错误。
  • 引擎产物不随仓库分发;未编引擎时插件仍可编译运行,engineReady 报 false。
  • 三端引擎各用独立 BUILDDIR(online/engine-build/<目标>,鸿蒙保持就地)。引擎是 单目标树,三端在同一目录里轮流 autogen.sh 会互相冲掉已编产物。新增 common/engine_builddir.sh 守卫,在配置前比对目标 OS=,不一致直接拒绝; 比对忽略大小写,否则 configure 写的 OS=iOS 与脚本传的 IOS 不匹配, iOS 编过一次后就无法重跑。
  • 修正 macOS 宿主编 Android 引擎:CPAndroidCommon.conf 写死 --build=x86_64-unknown-linux-gnu,导致 configure 去找 NDK 里不存在的 prebuilt/linux-x86_64/bin/clang。现自动追加 --build=$(config.guess) 覆盖 (经 shell 调用,该文件无执行位)。
  • 新增 common/run_detached.sh:nohup 不足以让三小时的构建活过调用方进程组, 改用 start_new_session 真正开新会话。配套 common/finish_all.sh 等两端引擎 收工后自动用 --with-lok 重打包。
  • 修正打包脚本里写错的引擎产物路径:Android 是 <BUILDDIR>/android/jniLibs/ 而非 android/source/jniLibs/,鸿蒙是 engine/ohos/libs/ 而非 workdir/CustomTarget/ohos/libs/。
最近更新
稳定tokens